Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
# Workflow name:
name: Close Pull Requests
# Workflow triggers:
on:
pull_request_target:
types: [opened]
# Workflow jobs:
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thank you for submitting a pull request. :raised_hands:
We greatly appreciate your willingness to submit a contribution. However, we are not accepting pull requests against this repository, as all development happens on the [main project repository](https://github.com/mautic/mautic).
We kindly request that you submit this pull request against the [respective directory](https://github.com/mautic/mautic/blob/head/plugins/MauticFocusBundle) of the main repository where we'll review and provide feedback. If this is your first Mautic contribution, be sure to read the [contributing guide](https://github.com/mautic/mautic/blob/4.x/.github/CONTRIBUTING.md) which provides guidelines and instructions for submitting contributions.
Thank you again, and we look forward to receiving your contribution! :smiley:
Best,
The Mautic team

View File

@@ -0,0 +1,246 @@
.focus-builder .website-preview .viewport-switcher {
position: absolute;
left: 0;
top: 75px;
z-index: 1025;
}
.focus-builder .website-preview .viewport-switcher .btn-viewport {
height: 50px;
width: 50px;
display: table;
}
.focus-builder .website-preview .viewport-switcher .btn-viewport i {
display: table-cell;
vertical-align: middle;
}
.focus-builder .website-preview .website-placeholder {
z-index: 1020;
margin-top: 10%;
}
.focus-builder #websiteScreenshot {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
margin: auto;
z-index: 0;
}
.focus-builder #websiteScreenshot .screenshot-container {
overflow: hidden;
}
.focus-builder #websiteScreenshot.css-device .screenshot-container {
height: 392px;
}
.builder-panel-focus {
overflow-y: scroll;
}
.builder-panel-focus #focusFormContent {
min-height: 1000px;
}
.builder-panel-focus .panel-title a {
display: block;
}
.builder-panel-focus .nav > li > a {
padding: 8px 16px;
}
.builder-panel-focus .focus-hide {
display: none;
}
.builder-panel-focus .focus-style.list-group-item,
.builder-panel-focus .focus-type.list-group-item {
background-color: #f5f5f5;
color: #555555;
cursor: pointer;
}
.builder-panel-focus .focus-style.list-group-item:not(.focus-active):not(.focus-hover) i.fa,
.builder-panel-focus .focus-type.list-group-item:not(.focus-active):not(.focus-hover) i.fa {
color: #8393a2 !important;
}
.builder-panel-focus .focus-active,
.builder-panel-focus .focus-hover {
background-color: #ffffff !important;
}
.builder-panel-focus .focus-type-form .visible-focus-type-form {
display: block !important;
}
.builder-panel-focus .focus-type-form .hidden-focus-type-form {
display: none !important;
}
.builder-panel-focus .focus-type-notice .visible-focus-type-notice {
display: inherit !important;
}
.builder-panel-focus .focus-type-notice .hidden-focus-type-notice {
display: none !important;
}
.builder-panel-focus .focus-type-link .visible-focus-type-link {
display: inherit !important;
}
.builder-panel-focus .focus-type-link .hidden-focus-type-link {
display: none !important;
}
.builder-panel-focus .focus-type-all .focus-type {
display: block !important;
}
.builder-panel-focus .focus-type-all .visible-focus-type-all {
display: inherit !important;
}
.builder-panel-focus .focus-type-all .hidden-focus-type-all {
display: none !important;
}
.builder-panel-focus .focus-style-bar .visible-focus-style-bar {
display: block !important;
}
.builder-panel-focus .focus-style-bar .hidden-focus-style-bar {
display: none !important;
}
.builder-panel-focus .focus-style-modal .visible-focus-style-modal {
display: inherit !important;
}
.builder-panel-focus .focus-style-modal .hidden-focus-style-modal {
display: none !important;
}
.builder-panel-focus .focus-style-notification .visible-focus-style-notification {
display: inherit !important;
}
.builder-panel-focus .focus-style-notification .hidden-focus-style-notification {
display: none !important;
}
.builder-panel-focus .focus-style-page .visible-focus-style-page {
display: inherit !important;
}
.builder-panel-focus .focus-style-page .hidden-focus-style-page {
display: none !important;
}
.builder-panel-focus .focus-style-all .focus-style {
display: block !important;
}
.builder-panel-focus .focus-style-all .visible-focus-style-all {
display: inherit !important;
}
.builder-panel-focus .focus-style-all .hidden-focus-style-all {
display: none !important;
}
.builder-panel-focus .label-site-color {
width: 25px;
height: 10px;
display: block;
float: left;
margin-right: 3px;
cursor: hand;
}
/* =============================================================================
https://github.com/callmenick/CSS-Device-Mockups
Licensed under the MIT license.
Copyright 2014, Call Me Nick.
COMMON STUFF FOR CSS DEVICES
============================================================================= */
.css-device {
position: relative;
margin: 0 auto;
}
.css-device__image {
display: block;
margin: 0;
padding: 0;
width: 100%;
height: auto;
}
/* =============================================================================
MOBILE
============================================================================= */
.css-device--mobile {
width: 270px;
height: 500px;
border-top: solid 24px #ddd;
border-left: solid 12px #ddd;
border-right: solid 12px #ddd;
border-bottom: solid 48px #ddd;
border-radius: 12px;
}
.css-device--mobile::before {
display: block;
position: absolute;
top: 12px;
left: -14px;
width: 2px;
height: 12px;
background-color: #bbb;
box-shadow: 0 0 0 0 #bbb, 0 24px 0 0 #bbb;
border-radius: 2px 0 0 2px;
content: "";
}
.css-device--mobile::after {
display: block;
position: absolute;
bottom: -36px;
left: 50%;
margin-left: -12px;
width: 24px;
height: 24px;
background-color: #bbb;
border-radius: 12px;
content: "";
}
@media all and (min-width: 720px) {
.css-device--mobile {
width: 270px;
border-top: solid 36px #ddd;
border-left: solid 18px #ddd;
border-right: solid 18px #ddd;
border-bottom: solid 72px #ddd;
border-radius: 18px;
}
.css-device--mobile::before {
top: 18px;
left: -21px;
width: 3px;
height: 18px;
background-color: #bbb;
box-shadow: 0 0 0 0 #bbb, 0 36px 0 0 #bbb;
border-radius: 3px 0 0 3px;
content: "";
}
.css-device--mobile::after {
bottom: -54px;
margin-left: -18px;
width: 36px;
height: 36px;
border-radius: 18px;
content: "";
}
}
.focus-builder .website-preview .website-placeholder.has-error input,
.focus-builder .website-preview .website-placeholder.has-error button {
border-color: #a94442;
}
#websiteScreenshot.mobile .screenshot-container {
position: absolute;
left: 50%;
width: 320px;
margin: 0px;
margin-top: 20px;
}
#websiteScreenshot.mobile .preview-body,
#websiteScreenshot.mobile #websiteCanvas {
margin: 0;
padding: 0;
border: none;
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 2;
left: -50%;
width: 320px;
height: 568px;
}
#websiteScreenshot.mobile #websiteCanvas {
z-index: 0;
border: 1px solid #000;
}
#websiteScreenshot.mobile #websiteCanvas iframe {
height: 100%;
width: 320px;
border: 0;
}

View File

@@ -0,0 +1,303 @@
.focus-builder {
.website-preview {
.viewport-switcher {
position: absolute;
left: 0;
top: 75px;
z-index: 1025;
.btn-viewport {
height: 50px;
width: 50px;
display: table;
i {
display: table-cell;
vertical-align: middle;
}
}
}
.website-placeholder {
z-index: 1020;
margin-top: 10%;
}
}
#websiteScreenshot {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
margin: auto;
z-index: 0;
.screenshot-container {
overflow: hidden;
}
&.css-device {
.screenshot-container {
height: 392px;
}
}
}
}
.builder-panel-focus {
overflow-y: scroll;
#focusFormContent {
min-height: 1000px;
}
.panel-title a {
display: block;
}
.nav > li > a {
padding: 8px 16px;
}
.focus-hide {
display: none;
}
.focus-style.list-group-item, .focus-type.list-group-item {
background-color: #f5f5f5;
color: #555555;
cursor: pointer;
&:not(.focus-active):not(.focus-hover) {
i.fa {
color: #8393a2 !important;;
}
}
}
.focus-active, .focus-hover {
background-color: #ffffff !important;
}
.focus-type-form {
.visible-focus-type-form {
display: block !important;
}
.hidden-focus-type-form {
display: none !important;
}
}
.focus-type-notice {
.visible-focus-type-notice {
display: inherit !important;
}
.hidden-focus-type-notice {
display: none !important;
}
}
.focus-type-link {
.visible-focus-type-link {
display: inherit !important;
}
.hidden-focus-type-link {
display: none !important;
}
}
.focus-type-all {
.focus-type {
display: block !important;
}
.visible-focus-type-all {
display: inherit !important;
}
.hidden-focus-type-all {
display: none !important;
}
}
.focus-style-bar {
.visible-focus-style-bar {
display: block !important;
}
.hidden-focus-style-bar {
display: none !important;
}
}
.focus-style-modal {
.visible-focus-style-modal {
display: inherit !important;
}
.hidden-focus-style-modal {
display: none !important;
}
}
.focus-style-notification {
.visible-focus-style-notification {
display: inherit !important;
}
.hidden-focus-style-notification {
display: none !important;
}
}
.focus-style-page {
.visible-focus-style-page {
display: inherit !important;
}
.hidden-focus-style-page {
display: none !important;
}
}
.focus-style-all {
.focus-style {
display: block !important;
}
.visible-focus-style-all {
display: inherit !important;
}
.hidden-focus-style-all {
display: none !important;
}
}
.label-site-color {
width: 25px;
height: 10px;
display: block;
float: left;
margin-right: 3px;
cursor: hand;
}
}
/* =============================================================================
https://github.com/callmenick/CSS-Device-Mockups
Licensed under the MIT license.
Copyright 2014, Call Me Nick.
COMMON STUFF FOR CSS DEVICES
============================================================================= */
.css-device {
position: relative;
margin: 0 auto;
}
.css-device__image {
display: block;
margin: 0;
padding: 0;
width: 100%;
height: auto;
}
/* =============================================================================
MOBILE
============================================================================= */
.css-device--mobile {
width: 270px;
height: 500px;
border-top: solid 24px #ddd;
border-left: solid 12px #ddd;
border-right: solid 12px #ddd;
border-bottom: solid 48px #ddd;
border-radius: 12px;
}
.css-device--mobile::before {
display: block;
position: absolute;
top: 12px;
left: -14px;
width: 2px;
height: 12px;
background-color: #bbb;
box-shadow: 0 0 0 0 #bbb, 0 24px 0 0 #bbb;
border-radius: 2px 0 0 2px;
content: "";
}
.css-device--mobile::after {
display: block;
position: absolute;
bottom: -36px;
left: 50%;
margin-left: -12px;
width: 24px;
height: 24px;
background-color: #bbb;
border-radius: 12px;
content: "";
}
@media all and (min-width: 720px) {
.css-device--mobile {
width: 270px;
border-top: solid 36px #ddd;
border-left: solid 18px #ddd;
border-right: solid 18px #ddd;
border-bottom: solid 72px #ddd;
border-radius: 18px;
}
.css-device--mobile::before {
top: 18px;
left: -21px;
width: 3px;
height: 18px;
background-color: #bbb;
box-shadow: 0 0 0 0 #bbb, 0 36px 0 0 #bbb;
border-radius: 3px 0 0 3px;
content: "";
}
.css-device--mobile::after {
bottom: -54px;
margin-left: -18px;
width: 36px;
height: 36px;
border-radius: 18px;
content: "";
}
}
.focus-builder .website-preview .website-placeholder.has-error input, .focus-builder .website-preview .website-placeholder.has-error button {
border-color: #a94442;
}
#websiteScreenshot.mobile .screenshot-container{
position: absolute;
left : 50%;
width: 320px;
margin: 0px;
margin-top: 20px;
}
#websiteScreenshot.mobile .preview-body, #websiteScreenshot.mobile #websiteCanvas {
margin: 0;
padding: 0;
border: none;
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 2;
left : -50%;
width: 320px;
height: 568px;
}
#websiteScreenshot.mobile #websiteCanvas {
z-index: 0;
border: 1px solid #000;
}
#websiteScreenshot.mobile #websiteCanvas iframe {
height: 100%;
width: 320px;
border: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,461 @@
//@ sourceURL=/plugins/MauticFocusBundle/Assets/js/focus.js
Mautic.disabledFocusActions = function(opener) {
if (typeof opener == 'undefined') {
opener = window;
}
var email = opener.mQuery('#campaignevent_properties_focus').val();
var disabled = email === '' || email === null;
opener.mQuery('#campaignevent_properties_editFocusButton').prop('disabled', disabled);
opener.mQuery('#campaignevent_properties_previewFocusButton').prop('disabled', disabled);
};
Mautic.focusOnLoad = function () {
if (mQuery('.builder').length) {
// Activate droppers
mQuery('.btn-dropper').each(function () {
mQuery(this).click(function () {
if (mQuery(this).hasClass('active')) {
// Deactivate
mQuery(this).removeClass('active btn-primary').addClass('btn-ghost');
mQuery('#websiteCanvas').css('cursor', 'inherit');
} else {
// Remove active state from all the droppers
mQuery('.btn-dropper').removeClass('active btn-primary').addClass('btn-ghost');
// Activate this dropper
mQuery(this).removeClass('btn-ghost').addClass('active btn-primary');
// Activate the cross hairs for image
mQuery('#websiteCanvas').css('cursor', 'crosshair');
}
});
});
// Update type
var activateType = function (el, thisType) {
mQuery('[data-focus-type]').removeClass('focus-active');
mQuery(el).addClass('focus-active');
mQuery('#focusFormContent').removeClass(function (index, css) {
return (css.match(/(^|\s)focus-type\S+/g) || []).join(' ');
}).addClass('focus-type-' + thisType);
mQuery('.focus-type-header').removeClass('text-danger');
mQuery('#focus_type').val(thisType);
var props = '.focus-' + thisType + '-properties';
mQuery('#focusTypeProperties').appendTo(
mQuery(props)
).removeClass('hide');
mQuery('#focusType .focus-properties').each(function () {
if (!mQuery(this).is(':hidden') && mQuery(this).data('focus-type') != thisType) {
mQuery(this).slideUp('fast', function () {
mQuery(this).hide();
});
}
});
if (mQuery(props).length) {
if (mQuery(props).is(':hidden')) {
mQuery(props).slideDown('fast');
}
}
}
mQuery('[data-focus-type]').on({
click: function () {
var thisType = mQuery(this).data('focus-type');
if (mQuery('#focus_type').val() == thisType) {
return;
}
activateType(this, thisType);
Mautic.focusUpdatePreview();
},
mouseenter: function () {
mQuery(this).addClass('focus-hover');
},
mouseleave: function () {
mQuery(this).removeClass('focus-hover');
}
});
var activateStyle = function (el, thisStyle) {
mQuery('[data-focus-style]').removeClass('focus-active');
mQuery(el).addClass('focus-active');
if (!mQuery('#focusType').hasClass('hidden-focus-style-all')) {
mQuery('#focusType').addClass('hidden-focus-style-all');
}
mQuery('#focusFormContent').removeClass(function (index, css) {
return (css.match(/(^|\s)focus-style\S+/g) || []).join(' ');
}).addClass('focus-style-' + thisStyle);
mQuery('.focus-style-header').removeClass('text-danger');
mQuery('#focus_style').val(thisStyle);
var props = '.focus-' + thisStyle + '-properties';
mQuery('#focusStyleProperties').appendTo(
mQuery(props)
).removeClass('hide');
mQuery('#focusStyle .focus-properties').each(function () {
if (!mQuery(this).is(':hidden')) {
mQuery(this).slideUp('fast', function () {
mQuery(this).hide();
});
}
});
if (mQuery(props).length) {
if (mQuery(props).is(':hidden')) {
mQuery(props).slideDown('fast');
}
}
};
// Update style
mQuery('[data-focus-style]').on({
click: function () {
var thisStyle = mQuery(this).data('focus-style');
if (mQuery('#focus_style').val() == thisStyle) {
return;
}
activateStyle(this, thisStyle);
Mautic.focusUpdatePreview();
},
mouseenter: function () {
mQuery(this).addClass('focus-hover');
},
mouseleave: function () {
mQuery(this).removeClass('focus-hover');
}
});
// Select the current type and style
var currentType = mQuery('#focus_type').val();
if (currentType) {
activateType(mQuery('[data-focus-type="' + currentType + '"]'), currentType);
}
var currentStyle = mQuery('#focus_style').val();
if (currentStyle) {
activateStyle(mQuery('[data-focus-style="' + currentStyle + '"]'), currentStyle);
}
mQuery('#focus_properties_content_font').on('chosen:showing_dropdown', function () {
// Little trickery to add style to the chosen dropdown font list
var arrayIndex = 1;
mQuery('#focus_properties_content_font option').each(function () {
mQuery('#focus_properties_content_font_chosen li[data-option-array-index="' + arrayIndex + '"]').css('fontFamily', mQuery(this).attr('value'));
arrayIndex++;
});
});
mQuery('.btn-fetch').on('click', function () {
var url = mQuery('#websiteUrlPlaceholderInput').val();
if (url) {
mQuery('#focus_website').val(url);
Mautic.launchFocusBuilder();
} else {
return;
}
});
Mautic.focusInitViewportSwitcher();
} else {
Mautic.initDateRangePicker();
}
if (mQuery('[data-conversion-rate-table]').length) {
Mautic.focusLoadConversionRateTable();
}
else {
Mautic.focusLoadViewCountTable();
}
};
Mautic.launchFocusBuilder = function (forceFetch) {
mQuery('body').css('overflow-y', 'hidden');
// Prevent preview updates till the website snapshot is loaded
Mautic.ignoreMauticFocusPreviewUpdate = true;
if (!mQuery('#builder-overlay').length) {
var builderCss = {
margin: "0",
padding: "0",
border: "none",
width: "100%",
height: "100%"
};
var spinnerLeft = (mQuery(document).width() - 300) / 2;
var overlay = mQuery('<div id="builder-overlay" class="modal-backdrop fade in"><div style="position: absolute; top:50%; left:' + spinnerLeft + 'px"><i class="ri-loader-3-line ri-spin ri-5x"></i></div></div>').css(builderCss).appendTo('.builder-content');
}
// Disable the close button until everything is loaded
mQuery('.btn-close-builder').prop('disabled', true);
// Activate the builder
mQuery('.builder').addClass('builder-active').removeClass('hide');
var url = mQuery('#focus_website').val();
if (!url) {
if (!mQuery('#focus_unlockId').val()) {
Mautic.setFocusDefaultColors();
}
mQuery('#builder-overlay').addClass('hide');
mQuery('.btn-close-builder').prop('disabled', false);
mQuery('#websiteUrlPlaceholderInput').prop('disabled', false);
mQuery('#websiteCanvas').html('');
mQuery('#websiteUrlPlaceholderInput').val('');
Mautic.focusUpdatePreview();
} else {
mQuery('#websiteUrlPlaceholderInput').val(url).prop('disabled', false);
let iframe = mQuery('#websiteCanvas iframe');
if (!forceFetch && iframe.length && url === iframe.attr('src')) {
return;
}
mQuery('#builder-overlay').removeClass('hide');
Mautic.loadedPreviewImage = url;
// Fetch image
var data = {
id: mQuery('#focus_unlockId').val(),
website: url
}
mQuery('.preview-body').html('');
Mautic.ajaxActionRequest('plugin:focus:checkIframeAvailability', data, function (response) {
if (response.errorMessage.length) {
mQuery('.website-placeholder')
.addClass('has-error')
.find('.help-block')
.html(response.errorMessage)
mQuery('#builder-overlay').hide();
mQuery('#websiteCanvas').html('');
mQuery('.builder-panel-top p button').prop('disabled', false);
return;
}
mQuery('#builder-overlay').addClass('hide');
mQuery('.btn-close-builder').prop('disabled', false);
mQuery('#websiteUrlPlaceholderInput').prop('disabled', false);
// Disable droppers
mQuery('.btn-dropper').addClass('disabled');
Mautic.focusCreateIframe(url);
Mautic.ignoreMauticFocusPreviewUpdate = false;
}, false, false, "GET");
}
};
// Called when you click on the show builder button
Mautic.focusUpdatePreview = function () {
// Generate a preview
var data = mQuery('form[name=focus]').formToArray();
Mautic.ajaxActionRequest('plugin:focus:generatePreview', data, function (response) {
var container = mQuery('<div />');
var innerContainer = mQuery('<div />').html(response.html);
if (mQuery('.btn-viewport').data('viewport') == 'mobile') {
innerContainer.addClass('mf-responsive');
} else {
innerContainer.removeClass('mf-responsive');
}
container.append(innerContainer);
mQuery('.preview-body').html(container);
if (!mQuery('.mf-bar').length && mQuery('.builder-content').length) {
mQuery('.builder-content').on('click', function () {
Mautic.closeFocusModal(mQuery('#focus_style').val());
});
mQuery('.mautic-focus').on('click', function (e) {
e.stopPropagation();
});
}
});
};
Mautic.setFocusDefaultColors = function () {
mQuery('#focus_properties_colors_primary').minicolors('value', '4e5d9d');
mQuery('#focus_properties_colors_text').minicolors('value', (mQuery('#focus_style').val() == 'bar') ? 'ffffff' : '000000');
mQuery('#focus_properties_colors_button').minicolors('value', 'fdb933');
mQuery('#focus_properties_colors_button_text').minicolors('value', 'ffffff');
};
Mautic.toggleBarCollapse = function () {
var svg = '.mf-bar-collapser-icon svg';
var currentSize = mQuery(svg).data('transform-size');
var currentDirection = mQuery(svg).data('transform-direction');
var currentScale = mQuery(svg).data('transform-scale');
var newDirection = (parseInt(currentDirection) * -1);
setTimeout(function () {
mQuery(svg).find('g').first().attr('transform', 'scale(' + currentScale + ') rotate(' + newDirection + ' ' + currentSize + ' ' + currentSize + ')');
mQuery(svg).data('transform-direction', newDirection);
}, 500);
if (mQuery('.mf-bar-collapser').hasClass('mf-bar-collapsed')) {
// Open
if (mQuery('.mf-bar').hasClass('mf-bar-top')) {
mQuery('.mf-bar').css('margin-top', 0);
} else {
mQuery('.mf-bar').css('margin-bottom', 0);
}
mQuery('.mf-bar-collapser').removeClass('mf-bar-collapsed');
} else {
// Collapse
if (mQuery('.mf-bar').hasClass('mf-bar-top')) {
mQuery('.mf-bar').css('margin-top', -60);
} else {
mQuery('.mf-bar').css('margin-bottom', -60);
}
mQuery('.mf-bar-collapser').addClass('mf-bar-collapsed');
}
}
Mautic.closeFocusModal = function (style) {
mQuery('.mf-' + style).remove();
if (mQuery('.mf-' + style + '-overlay').length) {
mQuery('.mf-' + style + '-overlay').remove();
}
}
Mautic.closeFocusBuilder = function (el) {
// Kill preview updates
if (typeof Mautic.ajaxActionXhr != 'undefined' && typeof Mautic.ajaxActionXhr['plugin:focus:generatePreview'] != 'undefined') {
Mautic.ajaxActionXhr['plugin:focus:generatePreview'].abort();
delete Mautic.ajaxActionXhr['plugin:focus:generatePreview'];
}
// mQuery('#websiteUrlPlaceholderInput').prop('disabled', true);
Mautic.stopIconSpinPostEvent();
// Hide builder
mQuery('.builder').removeClass('builder-active').addClass('hide');
mQuery('body').css('overflow-y', '');
};
Mautic.focusInitViewportSwitcher = function () {
mQuery('.btn-viewport').on('click', function () {
if (mQuery(this).data('viewport') == 'mobile') {
mQuery('.btn-viewport i').removeClass('ri-macbook-line ri-2x').addClass('ri-smartphone-line ri-2x');
mQuery(this).data('viewport', 'desktop');
Mautic.launchFocusBuilder(true);
} else {
mQuery('.btn-viewport i').removeClass('ri-smartphone-line ri-2x').addClass('ri-macbook-line ri-2x');
mQuery(this).data('viewport', 'mobile');
Mautic.launchFocusBuilder(true);
}
});
}
/**
* Create IFRAME with proper sizing
*/
Mautic.focusCreateIframe = function (url) {
let builderCss = {
"pointer-events": "none", // Disable clicks in iframe
};
if (mQuery('.btn-viewport').data('viewport') === 'mobile') {
mQuery('#websiteScreenshot').addClass('mobile');
} else {
builderCss.width = "100%";
builderCss.height = mQuery('#websiteScreenshot').height(); // 100% does not work. Needs to be specified
mQuery('#websiteScreenshot').removeClass('mobile');
}
// Not catching empty iframe
try {
mQuery('#websiteCanvas').html('<iframe src="'+url+'" scrolling="no" frameBorder="0"></iframe>');
mQuery('#websiteCanvas iframe').css(builderCss);
} catch(err) {
alert(err.toString());
} finally {
Mautic.focusUpdatePreview();
}
}
Mautic.focusLoadConversionRateTable = function() {
var $conversionRateTable = mQuery('[data-conversion-rate-table]');
var $conversionRateCells = mQuery('[data-conversion-rate-cell]', $conversionRateTable);
var $conversionRateTotalCell = mQuery('[data-conversion-rate-total-cell]', $conversionRateTable);
var $focusTotalViewsCell = mQuery('[data-focus-total-views-cell]');
var $focusTotalUniqueViewsCell = mQuery('[data-focus-total-unique-views-cell]');
var focusId = $conversionRateTable.data('entity-id');
var views = null;
var uniqueViews = null;
var clickThrough = null;
var updateTotalClickThroughRate = function() {
if (uniqueViews === null || clickThrough === null) return;
var totalConversionRate = uniqueViews > 0 ? Math.round(clickThrough / uniqueViews * 10000) / 100 : 0;
$conversionRateTotalCell.children('.spinner').remove();
$conversionRateTotalCell.prepend(totalConversionRate + '%')
};
Mautic.ajaxActionRequest('plugin:focus:getViewsCount', {focusId: focusId}, function(response){
views = response.views;
uniqueViews = response.uniqueViews;
$conversionRateCells.each(function(i, el) {
var $cell = mQuery(el);
var uniqueClicks = $cell.data('unique-hits');
var conversionRate = views > 0 ? Math.round(uniqueClicks / uniqueViews * 10000) / 100 : 0;
$cell.html(conversionRate + '%');
})
$focusTotalViewsCell.html(views);
$focusTotalUniqueViewsCell.html(uniqueViews);
updateTotalClickThroughRate();
}, false, true, "GET");
Mautic.ajaxActionRequest('plugin:focus:getClickThroughCount', {focusId: focusId}, function(response){
clickThrough = response.clickThrough;
updateTotalClickThroughRate();
}, false, true, "GET");
}
Mautic.focusLoadViewCountTable = function() {
var $viewTable = mQuery('[data-view-table]');
var $focusTotalViewsCell = mQuery('[data-focus-total-views-cell]');
var $focusTotalUniqueViewsCell = mQuery('[data-focus-total-unique-views-cell]');
var focusId = $viewTable.data('entity-id');
var views = null;
var uniqueViews = null;
Mautic.ajaxActionRequest('plugin:focus:getViewsCount', {focusId: focusId}, function(response){
views = response.views;
uniqueViews = response.uniqueViews;
$focusTotalViewsCell.html(views);
$focusTotalUniqueViewsCell.html(uniqueViews);
}, false, true, "GET");
}

View File

@@ -0,0 +1,86 @@
<?php
return [
'name' => 'Mautic Focus',
'description' => 'Drive visitor\'s focus on your website with Mautic Focus',
'version' => '1.0',
'author' => 'Mautic, Inc',
'routes' => [
'main' => [
'mautic_focus_index' => [
'path' => '/focus/{page}',
'controller' => 'MauticPlugin\MauticFocusBundle\Controller\FocusController::indexAction',
],
'mautic_focus_action' => [
'path' => '/focus/{objectAction}/{objectId}',
'controller' => 'MauticPlugin\MauticFocusBundle\Controller\FocusController::executeAction',
],
],
'public' => [
'mautic_focus_generate' => [
'path' => '/focus/{id}.js',
'controller' => 'MauticPlugin\MauticFocusBundle\Controller\PublicController::generateAction',
],
'mautic_focus_pixel' => [
'path' => '/focus/{id}/viewpixel.gif',
'controller' => 'MauticPlugin\MauticFocusBundle\Controller\PublicController::viewPixelAction',
],
],
'api' => [
'mautic_api_focusstandard' => [
'standard_entity' => true,
'name' => 'focus',
'path' => '/focus',
'controller' => MauticPlugin\MauticFocusBundle\Controller\Api\FocusApiController::class,
],
'mautic_api_focusjs' => [
'path' => '/focus/{id}/js',
'controller' => 'MauticPlugin\MauticFocusBundle\Controller\Api\FocusApiController::generateJsAction',
'method' => 'POST',
],
],
],
'services' => [
'other' => [
'mautic.focus.helper.token' => [
'class' => MauticPlugin\MauticFocusBundle\Helper\TokenHelper::class,
'arguments' => [
'mautic.focus.model.focus',
'router',
'mautic.security',
],
],
'mautic.focus.helper.iframe_availability_checker' => [
'class' => MauticPlugin\MauticFocusBundle\Helper\IframeAvailabilityChecker::class,
'arguments' => [
'translator',
],
],
],
],
'menu' => [
'main' => [
'mautic.focus' => [
'route' => 'mautic_focus_index',
'access' => 'focus:items:view',
'parent' => 'mautic.core.channels',
'priority' => 10,
],
],
],
'categories' => [
'plugin:focus' => [
'label' => 'mautic.focus',
'class' => MauticPlugin\MauticFocusBundle\Entity\Focus::class,
],
],
'parameters' => [
'website_snapshot_url' => 'https://mautic.net/api/snapshot',
'website_snapshot_key' => '',
],
];

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('MauticPlugin\\MauticFocusBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('MauticPlugin\\MauticFocusBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.focus.model.focus', MauticPlugin\MauticFocusBundle\Model\FocusModel::class);
};

View File

@@ -0,0 +1,131 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Controller;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Helper\InputHelper;
use MauticPlugin\MauticFocusBundle\Helper\IframeAvailabilityChecker;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class AjaxController extends CommonAjaxController
{
/**
* This method produces HTTP request checking headers which are blocking availability for iframe inheritance for other pages.
*/
public function checkIframeAvailabilityAction(Request $request, IframeAvailabilityChecker $availabilityChecker): JsonResponse
{
$url = $request->query->get('website');
return $availabilityChecker->check($url, $request->getScheme());
}
public function generatePreviewAction(Request $request): JsonResponse
{
$responseContent = ['html' => '', 'style' => ''];
$focus = $request->request->all();
if (isset($focus['focus'])) {
$focusArray = InputHelper::_($focus['focus']);
if (!empty($focusArray['style']) && !empty($focusArray['type'])) {
/** @var FocusModel $model */
$model = $this->getModel('focus');
$focusArray['id'] = 'preview';
$responseContent['html'] = $model->getContent($focusArray, true);
$responseContent['style'] = $focusArray['style']; // Required by JS in response
}
}
return $this->sendJsonResponse($responseContent);
}
public function getViewsCountAction(Request $request, CacheProviderTagAwareInterface $cacheProvider): JsonResponse
{
$focusId = (int) InputHelper::clean($request->query->get('focusId'));
if (0 === $focusId) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.core.error.badrequest'),
], 400);
}
$cacheTimeout = (int) $this->coreParametersHelper->get('cached_data_timeout');
$cacheItem = $cacheProvider->getItem('focus.viewsCount.'.$focusId);
if ($cacheItem->isHit()) {
$cacheItemValue = $cacheItem->get();
$viewsCount = $cacheItemValue['views'];
$uniqueViewsCount = $cacheItemValue['uniqueViews'];
} else {
/** @var FocusModel $model */
$model = $this->getModel('focus');
$focus = $model->getEntity($focusId);
if (null === $focus) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.api.call.notfound'),
], 404);
}
$viewsCount = $model->getViewsCount($focus);
$uniqueViewsCount = $model->getUniqueViewsCount($focus);
$cacheItem->set([
'views' => $viewsCount,
'uniqueViews' => $uniqueViewsCount,
]);
$cacheItem->tag("focus.{$focusId}");
$cacheItem->expiresAfter($cacheTimeout * 60);
$cacheProvider->save($cacheItem);
}
return $this->sendJsonResponse([
'success' => 1,
'views' => $viewsCount,
'uniqueViews' => $uniqueViewsCount,
]);
}
public function getClickThroughCountAction(Request $request, CacheProviderTagAwareInterface $cacheProvider): JsonResponse
{
$focusId = (int) InputHelper::clean($request->query->get('focusId'));
if (0 === $focusId) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.core.error.badrequest'),
], 400);
}
$cacheTimeout = (int) $this->coreParametersHelper->get('cached_data_timeout');
$cacheItem = $cacheProvider->getItem('focus.clickThroughCount.'.$focusId);
if ($cacheItem->isHit()) {
$clickThroughCount = $cacheItem->get();
} else {
/** @var FocusModel $model */
$model = $this->getModel('focus');
$focus = $model->getEntity($focusId);
if (null === $focus) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $this->translator->trans('mautic.api.call.notfound'),
], 404);
}
$clickThroughCount = $model->getClickThroughCount($focus);
$cacheItem->set($clickThroughCount);
$cacheItem->tag("focus.{$focusId}");
$cacheItem->expiresAfter($cacheTimeout * 60);
$cacheProvider->save($cacheItem);
}
return $this->sendJsonResponse([
'success' => 1,
'clickThrough' => $clickThroughCount,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Focus>
*/
class FocusApiController extends CommonApiController
{
/**
* @var FocusModel|null
*/
protected $model;
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper)
{
$focusModel = $modelFactory->getModel('focus');
\assert($focusModel instanceof FocusModel);
$this->model = $focusModel;
$this->entityClass = Focus::class;
$this->entityNameOne = 'focus';
$this->entityNameMulti = 'focus';
$this->permissionBase = 'focus:items';
$this->dataInputMasks = [
'html' => 'html',
'editor' => 'html',
];
$this->serializerGroups = [
'focusDetails',
'categoryList',
'publishDetails',
];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
public function generateJsAction($id)
{
$focus = $this->model->getEntity($id);
$view = $this->view(['js' => $this->model->generateJavascript($focus)], Response::HTTP_OK);
return $this->handleView($view);
}
}

View File

@@ -0,0 +1,266 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Controller\AbstractStandardFormController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Form\Type\DateRangeType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\FormBundle\Helper\FormFieldHelper;
use Mautic\PageBundle\Model\TrackableModel;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class FocusController extends AbstractStandardFormController
{
/**
* @phpstan-ignore-next-line
*/
public function __construct(
private CacheProviderTagAwareInterface $cacheProvider,
FormFactoryInterface $formFactory,
FormFieldHelper $fieldHelper,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($formFactory, $fieldHelper, $doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
protected function getTemplateBase(): string
{
return '@MauticFocus/Focus';
}
protected function getModelName(): string
{
return 'focus';
}
/**
* @param int $page
*/
public function indexAction(Request $request, $page = 1): Response
{
return parent::indexStandard($request, $page);
}
/**
* Generates new form and processes post data.
*
* @return JsonResponse|Response
*/
public function newAction(Request $request)
{
return parent::newStandard($request);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
return parent::editStandard($request, $objectId, $ignorePost);
}
/**
* Displays details on a Focus.
*
* @return array|JsonResponse|RedirectResponse|Response
*/
public function viewAction(Request $request, $objectId)
{
return parent::viewStandard($request, $objectId, 'focus', 'focus');
}
/**
* Clone an entity.
*
* @param int $objectId
*
* @return JsonResponse|RedirectResponse|Response
*/
public function cloneAction(Request $request, $objectId)
{
return parent::cloneStandard($request, $objectId);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return JsonResponse|RedirectResponse
*/
public function deleteAction(Request $request, $objectId)
{
return parent::deleteStandard($request, $objectId);
}
/**
* Deletes a group of entities.
*
* @return JsonResponse|RedirectResponse
*/
public function batchDeleteAction(Request $request)
{
return parent::batchDeleteStandard($request);
}
/**
* @throws \Exception
*/
public function getViewArguments(array $args, $action): array
{
$cacheTimeout = (int) $this->coreParametersHelper->get('cached_data_timeout');
if ('view' == $action) {
/** @var Focus $item */
$item = $args['viewParameters']['item'];
// For line graphs in the view
$dateRangeValues = $this->getCurrentRequest()->get('daterange', []);
$dateRangeForm = $this->formFactory->create(
DateRangeType::class,
$dateRangeValues,
[
'action' => $this->generateUrl(
'mautic_focus_action',
[
'objectAction' => 'view',
'objectId' => $item->getId(),
]
),
]
);
$statsDateFrom = new \DateTime($dateRangeForm->get('date_from')->getData());
$statsDateTo = new \DateTime($dateRangeForm->get('date_to')->getData());
$cacheKey = "focus.viewArguments.{$item->getId()}.{$statsDateFrom->getTimestamp()}.{$statsDateTo->getTimestamp()}";
$cacheItem = $this->cacheProvider->getItem($cacheKey);
if ($cacheItem->isHit()) {
[$stats, $trackables] = $cacheItem->get();
} else {
// invalidate cache for entire focus item to keep AJAX loaded data consistent
$this->cacheProvider->invalidateTags(["focus.{$item->getId()}"]);
/** @var FocusModel $model */
$model = $this->getModel('focus');
$stats = $model->getStats(
$item,
null,
$statsDateFrom,
$statsDateTo
);
if ('link' === $item->getType()) {
$trackableModel = $this->getModel('page.trackable');
\assert($trackableModel instanceof TrackableModel);
$trackables = $trackableModel->getTrackableList('focus', $item->getId());
$cacheItem->set([$stats, $trackables]);
$cacheItem->expiresAfter($cacheTimeout * 60);
$cacheItem->tag("focus.{$item->getId()}");
$this->cacheProvider->save($cacheItem);
}
}
$args['viewParameters']['stats'] = $stats;
$args['viewParameters']['dateRangeForm'] = $dateRangeForm->createView();
$args['viewParameters']['showConversionRate'] = true;
if (isset($trackables)) {
$args['viewParameters']['trackables'] = $trackables;
}
}
return $args;
}
/**
* @return mixed[]
*/
protected function getPostActionRedirectArguments(array $args, $action): array
{
$focus = $this->getCurrentRequest()->request->all()['focus'] ?? [];
$updateSelect = 'POST' === $this->getCurrentRequest()->getMethod()
? ($focus['updateSelect'] ?? false)
: $this->getCurrentRequest()->get('updateSelect', false);
if ($updateSelect) {
switch ($action) {
case 'new':
case 'edit':
$passthrough = $args['passthroughVars'];
$passthrough = array_merge(
$passthrough,
[
'updateSelect' => $updateSelect,
'id' => $args['entity']->getId(),
'name' => $args['entity']->getName(),
]
);
$args['passthroughVars'] = $passthrough;
break;
}
}
return $args;
}
/**
* @return array
*/
protected function getEntityFormOptions()
{
$focus = $this->getCurrentRequest()->request->all()['focus'] ?? [];
$updateSelect = 'POST' === $this->getCurrentRequest()->getMethod()
? ($focus['updateSelect'] ?? false)
: $this->getCurrentRequest()->get('updateSelect', false);
if ($updateSelect) {
return ['update_select' => $updateSelect];
}
}
/**
* Return array of options update select response.
*
* @param string $updateSelect HTML id of the select
* @param object $entity
* @param string $nameMethod name of the entity method holding the name
* @param string $groupMethod name of the entity method holding the select group
*/
protected function getUpdateSelectParams($updateSelect, $entity, $nameMethod = 'getName', $groupMethod = 'getLanguage'): array
{
return [
'updateSelect' => $updateSelect,
'id' => $entity->getId(),
'name' => $entity->$nameMethod(),
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Controller;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Helper\TrackingPixelHelper;
use Mautic\LeadBundle\Tracker\ContactTracker;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Event\FocusViewEvent;
use MauticPlugin\MauticFocusBundle\FocusEvents;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PublicController extends CommonController
{
/**
* @return array|\Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function generateAction($id)
{
// Don't store a visitor with this request
defined('MAUTIC_NON_TRACKABLE_REQUEST') || define('MAUTIC_NON_TRACKABLE_REQUEST', 1);
/** @var \MauticPlugin\MauticFocusBundle\Model\FocusModel $model */
$model = $this->getModel('focus');
$focus = $model->getEntity($id);
if ($focus) {
if (!$focus->isPublished()) {
return new Response('', Response::HTTP_NOT_FOUND);
}
$content = $model->generateJavascript($focus);
return new Response($content, 200, ['Content-Type' => 'application/javascript']);
} else {
return new Response('', Response::HTTP_NOT_FOUND);
}
}
/**
* @return Response
*/
public function viewPixelAction(Request $request, ContactTracker $contactTracker)
{
$id = $request->get('id', false);
if ($id) {
/** @var \MauticPlugin\MauticFocusBundle\Model\FocusModel $model */
$model = $this->getModel('focus');
$focus = $model->getEntity($id);
$lead = $contactTracker->getContact();
if ($focus && $focus->isPublished() && $lead) {
$stat = $model->addStat($focus, Stat::TYPE_NOTIFICATION, $request, $lead);
if ($stat && $this->dispatcher->hasListeners(FocusEvents::FOCUS_ON_VIEW)) {
$event = new FocusViewEvent($stat);
$this->dispatcher->dispatch($event, FocusEvents::FOCUS_ON_VIEW);
unset($event);
}
}
}
return TrackingPixelHelper::getResponse($request);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticFocusExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,593 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\FormBundle\Entity\Form;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(uriTemplate: '/focus_items', security: "is_granted('focus:items:viewown')"),
new Get(uriTemplate: '/focus_items/{id}', security: "is_granted('focus:items:viewown')"),
new Post(uriTemplate: '/focus_items', security: "is_granted('focus:items:create')"),
new Put(uriTemplate: '/focus_items/{id}', security: "is_granted('focus:items:editown')"),
new Patch(uriTemplate: '/focus_items/{id}', security: "is_granted('focus:items:editother')"),
new Delete(uriTemplate: '/focus_items/{id}', security: "is_granted('focus:items:deleteown')"),
],
normalizationContext: [
'groups' => ['focus:read'],
'swagger_definition_name' => 'Read',
],
denormalizationContext: [
'groups' => ['focus:write'],
'swagger_definition_name' => 'Write',
]
)]
class Focus extends FormEntity implements UuidInterface
{
use UuidTrait;
use ProjectTrait;
/**
* @var int
*/
#[Groups(['focus:read'])]
private $id;
/**
* @var string|null
*/
#[Groups(['focus:read', 'focus:write'])]
private $description;
/**
* @var string|null
*/
#[Groups(['focus:read', 'focus:write'])]
private $editor;
/**
* @var string|null
*/
#[Groups(['focus:read', 'focus:write'])]
private $html;
/**
* @var string|null
*/
#[Groups(['focus:read', 'focus:write'])]
private $htmlMode;
/**
* @var string
*/
#[Groups(['focus:read', 'focus:write'])]
private $name;
#[Groups(['focus:read', 'focus:write'])]
private $category;
/**
* @var string
*/
#[Groups(['focus:read', 'focus:write'])]
private $type;
/**
* @var string|null
*/
#[Groups(['focus:read', 'focus:write'])]
private $website;
/**
* @var string
*/
#[Groups(['focus:read', 'focus:write'])]
private $style;
/**
* @var \DateTimeInterface
*/
#[Groups(['focus:read', 'focus:write'])]
private $publishUp;
/**
* @var \DateTimeInterface
*/
#[Groups(['focus:read', 'focus:write'])]
private $publishDown;
/**
* @var array<mixed>
*/
#[Groups(['focus:read', 'focus:write'])]
private $properties = [];
/**
* @var array
*/
#[Groups(['focus:read', 'focus:write'])]
private $utmTags = [];
/**
* @var int|null
*/
private $form;
/**
* @var string|null
*/
private $cache;
public function __construct()
{
$this->initializeProjects();
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint(
'name',
new NotBlank(
[
'message' => 'mautic.core.name.required',
]
)
);
$metadata->addPropertyConstraint(
'type',
new NotBlank(
['message' => 'mautic.focus.error.select_type']
)
);
$metadata->addPropertyConstraint(
'style',
new NotBlank(
['message' => 'mautic.focus.error.select_style']
)
);
}
public function __clone()
{
$this->id = null;
parent::__clone();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('focus')
->setCustomRepositoryClass(FocusRepository::class)
->addIndex(['focus_type'], 'focus_type')
->addIndex(['style'], 'focus_style')
->addIndex(['form_id'], 'focus_form')
->addIndex(['name'], 'focus_name');
$builder->addIdColumns();
$builder->addCategory();
$builder->addNamedField('type', 'string', 'focus_type');
$builder->addField('style', 'string');
$builder->addNullableField('website', 'string');
$builder->addPublishDates();
$builder->addNullableField('properties', 'array');
$builder->createField('utmTags', 'array')
->columnName('utm_tags')
->nullable()
->build();
$builder->addNamedField('form', 'integer', 'form_id', true);
$builder->addNullableField('cache', 'text');
$builder->createField('htmlMode', 'string')
->columnName('html_mode')
->nullable()
->build();
$builder->addNullableField('editor', 'text');
$builder->addNullableField('html', 'text');
static::addUuidField($builder);
self::addProjectsField($builder, 'focus_projects_xref', 'focus_id');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('focus')
->addListProperties(
[
'id',
'name',
'category',
]
)
->addProperties(
[
'description',
'type',
'website',
'style',
'publishUp',
'publishDown',
'properties',
'utmTags',
'form',
'htmlMode',
'html',
'editor',
'cache',
]
)
->build();
self::addProjectsInLoadApiMetadata($metadata, 'focus');
}
public function toArray(): array
{
return get_object_vars($this);
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @return mixed
*/
public function getDescription()
{
return $this->description;
}
/**
* @param mixed $description
*
* @return Focus
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* @return mixed
*/
public function getEditor()
{
return $this->editor;
}
/**
* @return Focus
*/
public function setEditor($editor)
{
$this->isChanged('editor', $editor);
$this->editor = $editor;
return $this;
}
/**
* @return mixed
*/
public function getHtml()
{
return $this->html;
}
/**
* @return Focus
*/
public function setHtml($html)
{
$this->isChanged('html', $html);
$this->html = $html;
return $this;
}
/**
* @return mixed
*/
public function getHtmlMode()
{
return $this->htmlMode;
}
/**
* @return Focus
*/
public function setHtmlMode($htmlMode)
{
$this->isChanged('htmlMode', $htmlMode);
$this->htmlMode = $htmlMode;
return $this;
}
/**
* @return mixed
*/
public function getName()
{
return $this->name;
}
/**
* @param mixed $name
*
* @return Focus
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return mixed
*/
public function getCategory()
{
return $this->category;
}
/**
* @param mixed $category
*
* @return Focus
*/
public function setCategory($category)
{
$this->isChanged('category', $category);
$this->category = $category;
return $this;
}
/**
* @return mixed
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* @param mixed $publishUp
*
* @return Focus
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* @return mixed
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @param mixed $publishDown
*
* @return Focus
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* @return array<mixed>
*/
public function getProperties()
{
return $this->properties;
}
/**
* @param array<mixed> $properties
*
* @return Focus
*/
public function setProperties($properties)
{
$this->isChanged('properties', $properties);
$this->properties = $properties;
return $this;
}
/**
* @return array
*/
public function getUtmTags()
{
return $this->utmTags;
}
/**
* @param array $utmTags
*/
public function setUtmTags($utmTags)
{
$this->isChanged('utmTags', $utmTags);
$this->utmTags = $utmTags;
return $this;
}
/**
* @return mixed
*/
public function getType()
{
return $this->type;
}
/**
* @param mixed $type
*
* @return Focus
*/
public function setType($type)
{
$this->isChanged('type', $type);
$this->type = $type;
return $this;
}
/**
* @return mixed
*/
public function getStyle()
{
return $this->style;
}
/**
* @param mixed $style
*
* @return Focus
*/
public function setStyle($style)
{
$this->isChanged('style', $style);
$this->style = $style;
return $this;
}
/**
* @return mixed
*/
public function getWebsite()
{
return $this->website;
}
/**
* @param mixed $website
*
* @return Focus
*/
public function setWebsite($website)
{
$this->isChanged('website', $website);
$this->website = $website;
return $this;
}
/**
* @return mixed
*/
public function getForm()
{
return $this->form;
}
/**
* @param mixed $form
*
* @return Focus
*/
public function setForm($form)
{
if ($form instanceof Form) {
$form = $form->getId();
}
$this->isChanged('form', $form);
$this->form = $form;
return $this;
}
/**
* @return mixed
*/
public function getCache()
{
return $this->cache;
}
/**
* @param mixed $cache
*
* @return Focus
*/
public function setCache($cache)
{
$this->cache = $cache;
return $this;
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Focus>
*/
class FocusRepository extends CommonRepository
{
use ProjectRepositoryTrait;
/**
* @return array
*/
public function findByForm($formId)
{
return $this->findBy(
[
'form' => (int) $formId,
]
);
}
public function getEntities(array $args = [])
{
$alias = $this->getTableAlias();
$q = $this->_em
->createQueryBuilder()
->select($alias)
->from(Focus::class, $alias, $alias.'.id');
if (empty($args['iterable_mode'])) {
$q->leftJoin($alias.'.category', 'c');
}
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, ['f.name', 'f.website']);
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
return match ($filter->command) {
$this->translator->trans('mautic.project.searchcommand.name'),
$this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US') => $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'focus_id',
'focus_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
),
default => $this->addStandardSearchCommandWhereClause($q, $filter),
};
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
return array_merge([
'mautic.project.searchcommand.name',
], $this->getStandardSearchCommands());
}
/**
* @return array<array<string>>
*/
protected function getDefaultOrder(): array
{
return [
[$this->getTableAlias().'.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'f';
}
/**
* @return array
*/
public function getFocusList($currentId)
{
$q = $this->createQueryBuilder('f');
$q->select('partial f.{id, name, description}')->orderBy('f.name');
return $q->getQuery()->getArrayResult();
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\LeadBundle\Entity\Lead;
class Stat
{
// Used for querying stats
public const TYPE_FORM = 'submission';
public const TYPE_CLICK = 'click';
public const TYPE_NOTIFICATION = 'view';
/**
* @var int
*/
private $id;
/**
* @var Focus
*/
private $focus;
/**
* @var string
*/
private $type;
/**
* @var int|null
*/
private $typeId;
/**
* @var \DateTimeInterface
*/
private $dateAdded;
/**
* @var ?Lead
*/
private $lead;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('focus_stats')
->setCustomRepositoryClass(StatRepository::class)
->addIndex(['type'], 'focus_type')
->addIndex(['type', 'type_id'], 'focus_type_id')
->addIndex(['date_added'], 'focus_date_added');
$builder->addId();
$builder->createManyToOne('focus', 'Focus')
->addJoinColumn('focus_id', 'id', false, false, 'CASCADE')
->build();
$builder->addField('type', 'string');
$builder->addNamedField('typeId', 'integer', 'type_id', true);
$builder->addNamedField('dateAdded', 'datetime', 'date_added');
$builder->addLead(true, 'SET NULL');
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @return mixed
*/
public function getFocus()
{
return $this->focus;
}
/**
* @param mixed $focus
*
* @return Stat
*/
public function setFocus($focus)
{
$this->focus = $focus;
return $this;
}
/**
* @return mixed
*/
public function getType()
{
return $this->type;
}
/**
* @param mixed $type
*
* @return Stat
*/
public function setType($type)
{
$this->type = $type;
return $this;
}
/**
* @return mixed
*/
public function getTypeId()
{
return $this->typeId;
}
/**
* @param mixed $typeId
*
* @return Stat
*/
public function setTypeId($typeId)
{
$this->typeId = $typeId;
return $this;
}
/**
* @return mixed
*/
public function getDateAdded()
{
return $this->dateAdded;
}
/**
* @param mixed $dateAdded
*
* @return Stat
*/
public function setDateAdded($dateAdded)
{
$this->dateAdded = $dateAdded;
return $this;
}
/**
* @return ?Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @return Stat
*/
public function setLead(Lead $lead)
{
$this->lead = $lead;
return $this;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Stat>
*/
class StatRepository extends CommonRepository
{
use TimelineTrait;
/**
* Fetch the base stat data from the database.
*
* @param int $id
*
* @return mixed
*/
public function getStats($id, $type, $fromDate = null)
{
$q = $this->createQueryBuilder('s');
$expr = $q->expr()->andX(
$q->expr()->eq('IDENTITY(s.focus)', (int) $id),
$q->expr()->eq('s.type', ':type')
);
if ($fromDate) {
$expr->add(
$q->expr()->gte('s.dateAdded', ':fromDate')
);
$q->setParameter('fromDate', $fromDate);
}
$q->where($expr)
->setParameter('type', $type);
return $q->getQuery()->getArrayResult();
}
public function getViewsCount(int $id): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('COUNT(s.id) as views_count')
->from(MAUTIC_TABLE_PREFIX.'focus_stats', 's');
$expr = $q->expr()->and(
$q->expr()->eq('s.focus_id', ':id'),
$q->expr()->eq('s.type', ':type')
);
$q->where($expr)
->setParameter('id', $id)
->setParameter('type', Stat::TYPE_NOTIFICATION);
return (int) $q->executeQuery()->fetchOne();
}
public function getUniqueViewsCount(int $id): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('COUNT(DISTINCT s.lead_id) as views_count')
->from(MAUTIC_TABLE_PREFIX.'focus_stats', 's');
$expr = $q->expr()->and(
$q->expr()->eq('s.focus_id', ':id'),
$q->expr()->eq('s.type', ':type')
);
$q->where($expr)
->setParameter('id', $id)
->setParameter('type', Stat::TYPE_NOTIFICATION);
return (int) $q->executeQuery()->fetchOne();
}
public function getClickThroughCount(int $id): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('COUNT(DISTINCT s.lead_id) as click_through_count')
->from(MAUTIC_TABLE_PREFIX.'focus_stats', 's');
$expr = $q->expr()->and(
$q->expr()->eq('s.focus_id', ':id'),
$q->expr()->eq('s.type', ':type')
);
$q->where($expr)
->setParameter('id', $id)
->setParameter('type', Stat::TYPE_CLICK);
return (int) $q->executeQuery()->fetchOne();
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
public function getStatsViewByLead(?int $leadId=null, array $options = []): array
{
return $this->getStatsByLeadAndType(Stat::TYPE_NOTIFICATION, $leadId, $options);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
public function getStatsClickByLead(?int $leadId=null, array $options = []): array
{
return $this->getStatsByLeadAndType(Stat::TYPE_CLICK, $leadId, $options);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function getStatsByLeadAndType(string $type, ?int $leadId=null, array $options = []): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'focus_stats', 's')
->select('s.id, s.lead_id, s.type, s.date_added, f.id as focus_id, f.name as focus_name')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'focus', 'f', 's.focus_id=f.id');
$q->where($q->expr()->eq('s.type', ':type'));
if ($leadId) {
$q->andWhere($q->expr()->eq('s.lead_id', (int) $leadId));
}
$q->setParameter('type', $type);
if (isset($options['search']) && $options['search']) {
$q->andWhere($q->expr()->or(
$q->expr()->like('f.name', $q->expr()->literal($options['search'].'%')),
$q->expr()->eq('s.type', $q->expr()->literal($options['search']))
));
}
return $this->getTimelineResults($q, $options, 'f.name', 's.date_added');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
class FocusEvent extends CommonEvent
{
/**
* @param bool|false $isNew
*/
public function __construct(Focus $focus, $isNew = false)
{
$this->entity = $focus;
$this->isNew = $isNew;
}
/**
* Returns the Focus entity.
*
* @return Focus
*/
public function getFocus()
{
return $this->entity;
}
/**
* Sets the Focus entity.
*/
public function setFocus(Focus $focus): void
{
$this->entity = $focus;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Event;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use Symfony\Contracts\EventDispatcher\Event;
class FocusViewEvent extends Event
{
public function __construct(
private Stat $stat,
) {
}
/**
* @return Stat
*/
public function getStat()
{
return $this->stat;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\PageBundle\Helper\TrackingHelper;
use MauticPlugin\MauticFocusBundle\FocusEvents;
use MauticPlugin\MauticFocusBundle\Form\Type\FocusShowType;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
public function __construct(
private TrackingHelper $trackingHelper,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
FocusEvents::ON_CAMPAIGN_TRIGGER_ACTION => ['onCampaignTriggerAction', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$action = [
'label' => 'mautic.focus.campaign.event.show_focus',
'description' => 'mautic.focus.campaign.event.show_focus_descr',
'eventName' => FocusEvents::ON_CAMPAIGN_TRIGGER_ACTION,
'formType' => FocusShowType::class,
'formTheme' => '@MauticFocus/FormTheme/FocusShowList/focusshow_list_row.html.twig',
'formTypeOptions' => ['update_select' => 'campaignevent_properties_focus'],
'connectionRestrictions' => [
'anchor' => [
'decision.inaction',
],
'source' => [
'decision' => [
'page.pagehit',
],
],
],
];
$event->addAction('focus.show', $action);
}
public function onCampaignTriggerAction(CampaignExecutionEvent $event)
{
$focusId = (int) $event->getConfig()['focus'];
if (!$focusId) {
return $event->setResult(false);
}
$values = [];
$values['focus_item'][] = ['id' => $focusId, 'js' => $this->router->generate('mautic_focus_generate', ['id' => $focusId], UrlGeneratorInterface::ABSOLUTE_URL)];
$this->trackingHelper->updateCacheItem($values);
return $event->setResult(true);
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\AssetBundle\Helper\TokenHelper as AssetTokenHelper;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\TokenHelper;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Helper\TokenHelper as PageTokenHelper;
use Mautic\PageBundle\Model\TrackableModel;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\ReportEvents;
use MauticPlugin\MauticFocusBundle\Event\FocusEvent;
use MauticPlugin\MauticFocusBundle\FocusEvents;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
class FocusSubscriber implements EventSubscriberInterface
{
public function __construct(
private RouterInterface $router,
private IpLookupHelper $ipHelper,
private AuditLogModel $auditLogModel,
private TrackableModel $trackableModel,
private PageTokenHelper $pageTokenHelper,
private AssetTokenHelper $assetTokenHelper,
private FocusModel $focusModel,
private RequestStack $requestStack,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
FocusEvents::POST_SAVE => ['onFocusPostSave', 0],
FocusEvents::POST_DELETE => ['onFocusDelete', 0],
FocusEvents::TOKEN_REPLACEMENT => ['onTokenReplacement', 0],
ReportEvents::REPORT_ON_BUILD => ['onReportBuild', -10],
];
}
/*
* Check and hijack the form's generate link if the ID has mf- in it
*/
public function onKernelRequest(RequestEvent $event): void
{
if ($event->isMainRequest()) {
// get the current event request
$request = $event->getRequest();
$requestUri = $request->getRequestUri();
$formGenerateUrl = $this->router->generate('mautic_form_generateform');
if (str_contains($requestUri, $formGenerateUrl)) {
$id = InputHelper::_($this->requestStack->getCurrentRequest()->get('id'));
if (str_starts_with($id, 'mf-')) {
$mfId = str_replace('mf-', '', $id);
$focusGenerateUrl = $this->router->generate('mautic_focus_generate', ['id' => $mfId]);
$event->setResponse(new RedirectResponse($focusGenerateUrl));
}
}
}
}
/**
* Add an entry to the audit log.
*/
public function onFocusPostSave(FocusEvent $event): void
{
$entity = $event->getFocus();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'focus',
'object' => 'focus',
'objectId' => $entity->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onFocusDelete(FocusEvent $event): void
{
$entity = $event->getFocus();
$log = [
'bundle' => 'focus',
'object' => 'focus',
'objectId' => $entity->deletedId,
'action' => 'delete',
'details' => ['name' => $entity->getName()],
'ipAddress' => $this->ipHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
public function onReportBuild(ReportBuilderEvent $event): void
{
$tables = $event->getTables();
if (!isset($tables['audit.log']['columns']['al.bundle']['list'])) {
return;
}
$tables['audit.log']['columns']['al.object']['list']['focus'] = 'focus';
$event->addTable('audit.log', $tables['audit.log']);
}
public function onTokenReplacement(MauticEvents\TokenReplacementEvent $event): void
{
/** @var Lead $lead */
$lead = $event->getLead();
$content = $event->getContent();
$clickthrough = $event->getClickthrough();
if ($content) {
$tokens = array_merge(
$this->pageTokenHelper->findPageTokens($content, $clickthrough),
$this->assetTokenHelper->findAssetTokens($content, $clickthrough)
);
if ($lead && $lead->getId()) {
$tokens = array_merge($tokens, TokenHelper::findLeadTokens($content, $lead->getProfileFields()));
}
[$content, $trackables] = $this->trackableModel->parseContentForTrackables(
$content,
$tokens,
'focus',
$clickthrough['focus_id']
);
$focus = $this->focusModel->getEntity($clickthrough['focus_id']);
/**
* @var string $token
* @var Trackable $trackable
*/
foreach ($trackables as $token => $trackable) {
$tokens[$token] = $this->trackableModel->generateTrackableUrl($trackable, $clickthrough, false, $focus->getUtmTags());
}
$tokens = array_merge($tokens, $event->getTokens());
$content = str_replace(array_keys($tokens), array_values($tokens), $content);
$event->setContent($content);
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\FormBundle\Event as Events;
use Mautic\FormBundle\FormEvents;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class FormSubscriber implements EventSubscriberInterface
{
public function __construct(
private FocusModel $model,
) {
}
public static function getSubscribedEvents(): array
{
return [
FormEvents::FORM_POST_DELETE => ['onFormDelete', 0],
];
}
/**
* Add a delete entry to the audit log.
*/
public function onFormDelete(Events\FormEvent $event): void
{
$form = $event->getForm();
$formId = $form->deletedId;
$foci = $this->model->getRepository()->findByForm($formId);
if (empty($foci)) {
return;
}
// Rebuild each focus
/** @var \MauticPlugin\MauticFocusBundle\Entity\Focus $focus */
foreach ($foci as $focus) {
$focus->setForm(null);
}
$this->model->saveEntities($foci);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\FocusEventTypes;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private Translator $translator,
private RouterInterface $router,
private FocusModel $focusModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
$eventViewTypeName = $this->translator->trans('mautic.focus.event.view');
$event->addEventType(FocusEventTypes::FOCUS_ON_VIEW, $eventViewTypeName);
$eventViewApplicable = $event->isApplicable(FocusEventTypes::FOCUS_ON_VIEW);
$eventClickTypeName = $this->translator->trans('mautic.focus.event.click');
$event->addEventType(FocusEventTypes::FOCUS_ON_CLICK, $eventClickTypeName);
$eventClickApplicable = $event->isApplicable(FocusEventTypes::FOCUS_ON_CLICK);
$event->addSerializerGroup('focusList');
$leadId = $event->getLeadId();
$statsViewsByLead = $this->focusModel->getStatRepository()->getStatsViewByLead($leadId, $event->getQueryOptions());
$statsClickByLead = $this->focusModel->getStatRepository()->getStatsClickByLead($leadId, $event->getQueryOptions());
if (!$event->isEngagementCount()) {
$icon = 'ri-search-line';
// Add the view to the event array
foreach (array_merge($statsViewsByLead['results'] ?? [], $statsClickByLead['results'] ?? []) as $statsView) {
if (((Stat::TYPE_CLICK == $statsView['type']) && $eventClickApplicable)
|| ((Stat::TYPE_NOTIFICATION == $statsView['type']) && $eventViewApplicable)) {
$eventLabel = [
'label' => $statsView['focus_name'],
'href' => $this->router->generate('mautic_focus_action', ['objectAction' => 'view', 'objectId' => $statsView['focus_id']]),
];
$eventType = (Stat::TYPE_NOTIFICATION == $statsView['type']) ? FocusEventTypes::FOCUS_ON_VIEW : FocusEventTypes::FOCUS_ON_CLICK;
$event->addEvent(
[
'event' => $eventType,
'eventId' => $eventType.'.'.$statsView['id'],
'eventLabel' => $eventLabel,
'eventType' => (Stat::TYPE_NOTIFICATION == $statsView['type']) ? $eventViewTypeName : $eventClickTypeName,
'timestamp' => $statsView['date_added'],
'icon' => $icon,
'contactId' => $leadId,
]
);
}
}
// Add to counter view
$event->addToCounter(FocusEventTypes::FOCUS_ON_VIEW, $statsViewsByLead['total'] ?? 0);
// Add to counter click
$event->addToCounter(FocusEventTypes::FOCUS_ON_CLICK, $statsClickByLead['total'] ?? 0);
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\CoreBundle\Helper\BuilderTokenHelperFactory;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Event\PageBuilderEvent;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\PageEvents;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
class PageSubscriber implements EventSubscriberInterface
{
private string $regex = '{focus=(.*?)}';
public function __construct(
private CorePermissions $security,
private FocusModel $model,
private RouterInterface $router,
private BuilderTokenHelperFactory $builderTokenHelperFactory,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_DISPLAY => ['onPageDisplay', 0],
PageEvents::PAGE_ON_BUILD => ['onPageBuild', 0],
];
}
/**
* Add forms to available page tokens.
*/
public function onPageBuild(PageBuilderEvent $event): void
{
if ($event->tokensRequested($this->regex)) {
$tokenHelper = $this->builderTokenHelperFactory->getBuilderTokenHelper('focus', $this->model->getPermissionBase(), 'MauticFocusBundle', 'mautic.focus');
$event->addTokensFromHelper($tokenHelper, $this->regex, 'name');
}
}
public function onPageDisplay(PageDisplayEvent $event): void
{
$content = $event->getContent();
$regex = '/'.$this->regex.'/i';
preg_match_all($regex, $content, $matches);
if (count($matches[0])) {
foreach ($matches[1] as $id) {
$focus = $this->model->getEntity((int) $id);
if (null !== $focus
&& (
$focus->isPublished()
|| $this->security->hasEntityAccess(
'focus:items:viewown',
'focus:items:viewother',
$focus->getCreatedBy()
)
)
) {
$script = '<script src="'.$this->router->generate('mautic_focus_generate', ['id' => $id], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL)
.'" type="text/javascript" charset="utf-8" async="async"></script>';
$content = preg_replace('#{focus='.$id.'}#', $script, $content);
} else {
$content = preg_replace('#{focus='.$id.'}#', '', $content);
}
}
}
$event->setContent($content);
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_FOCUS_STATS = 'focus_stats';
public const CONTEXT_FOCUS_LEADS = 'focus_leads';
public const FOCUS_GROUP = 'focus';
public const PREFIX_FOCUS = 'f';
public const PREFIX_STATS = 'fs';
public const PREFIX_REDIRECTS = 'r';
public const PREFIX_TRACKABLES = 't';
public const PREFIX_CATEGORIES = 'c';
public const PREFIX_LEADS = 'l';
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', 0],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', 0],
];
}
/**
* Add available tables and columns to the report builder lookup.
*/
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_FOCUS_LEADS, self::CONTEXT_FOCUS_STATS])) {
return;
}
$commonColumns = [
self::PREFIX_FOCUS.'.id' => [
'label' => 'mautic.report.focus.id',
'type' => 'int',
'link' => 'mautic_focus_action',
'alias' => 'focus_id',
'formula' => 'MAX('.self::PREFIX_FOCUS.'.id)',
],
self::PREFIX_FOCUS.'.name' => [
'label' => 'mautic.report.focus.name',
'type' => 'string',
'alias' => 'focus_name',
'formula' => 'MAX('.self::PREFIX_FOCUS.'.name)',
],
self::PREFIX_FOCUS.'.category' => [
'label' => 'mautic.report.focus.category',
'type' => 'string',
'alias' => 'category_name',
'formula' => 'MAX('.self::PREFIX_CATEGORIES.'.title)',
],
self::PREFIX_FOCUS.'.description' => [
'label' => 'mautic.report.focus.description',
'type' => 'string',
'alias' => 'focus_desc',
'formula' => 'MAX('.self::PREFIX_FOCUS.'.description)',
],
self::PREFIX_FOCUS.'.focus_type' => [
'label' => 'mautic.focus.thead.type',
'type' => 'string',
'alias' => 'focus_type',
'formula' => 'MAX('.self::PREFIX_FOCUS.'.focus_type)',
],
self::PREFIX_FOCUS.'.style' => [
'label' => 'mautic.report.focus.style',
'type' => 'string',
'alias' => 'focus_style',
'formula' => 'MAX('.self::PREFIX_FOCUS.'.style)',
],
self::PREFIX_STATS.'.type' => [
'label' => 'mautic.focus.interaction',
'type' => 'string',
'alias' => 'interaction_type',
],
self::PREFIX_TRACKABLES.'.hits' => [
'label' => 'mautic.report.focus.hits',
'type' => 'int',
'alias' => 'hit_count',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "view" THEN (
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "view"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
GROUP BY fs2.focus_id
)
WHEN '.self::PREFIX_STATS.'.type = "submission" THEN (
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "submission"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
GROUP BY fs2.focus_id
)
ELSE MAX('.self::PREFIX_TRACKABLES.'.hits)
END',
],
self::PREFIX_STATS.'.conversion_rate_submission' => [
'label' => 'mautic.report.focus.ratio.submission',
'type' => 'string',
'suffix' => ' %',
'alias' => 'conversion_rate_submission',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "submission" THEN (
SELECT
ROUND(
(
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "submission"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
) * 100.0 /
NULLIF((
SELECT COUNT(fs3.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs3
WHERE fs3.type = "view"
AND fs3.focus_id = '.self::PREFIX_STATS.'.focus_id
), 0)
, 2)
)
ELSE NULL
END',
],
self::PREFIX_STATS.'.conversion_rate_click' => [
'label' => 'mautic.report.focus.ratio.click',
'type' => 'string',
'suffix' => ' %',
'alias' => 'conversion_rate_click',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "click" THEN (
SELECT
ROUND(
(
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "click"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
) * 100.0 /
NULLIF((
SELECT COUNT(fs3.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs3
WHERE fs3.type = "view"
AND fs3.focus_id = '.self::PREFIX_STATS.'.focus_id
), 0)
, 2)
)
ELSE NULL
END',
],
self::PREFIX_REDIRECTS.'.url' => [
'label' => 'url',
'type' => 'string',
'alias' => 'redirect_url',
'formula' => 'MAX('.self::PREFIX_REDIRECTS.'.url)',
],
];
$statsColumns = [self::PREFIX_TRACKABLES.'.unique_hits' => [
'label' => 'mautic.report.focus.uniquehits',
'type' => 'int',
'alias' => 'unique_hit_count',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "view" THEN (
SELECT COUNT(DISTINCT fs2.lead_id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "view"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
)
WHEN '.self::PREFIX_STATS.'.type = "submission" THEN (
SELECT COUNT(DISTINCT fs2.lead_id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "submission"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
)
ELSE MAX('.self::PREFIX_TRACKABLES.'.unique_hits)
END',
]];
$data = [
'display_name' => 'mautic.focus.graph.stats',
'columns' => array_merge($commonColumns, $statsColumns),
];
$event->addTable(self::CONTEXT_FOCUS_STATS, $data, self::FOCUS_GROUP);
if ($event->checkContext([self::CONTEXT_FOCUS_LEADS])) {
$this->addFocusLeadsTable($event, $commonColumns);
}
}
/**
* @param array<string, array<string, string>> $columns
*/
private function addFocusLeadsTable(ReportBuilderEvent $event, array $columns): void
{
$columnsLeads = [
self::PREFIX_TRACKABLES.'.hits' => [
'label' => 'mautic.report.focus.hits',
'type' => 'int',
'alias' => 'hit_count',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "view" THEN (
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "view"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs2.lead_id = '.self::PREFIX_LEADS.'.id
GROUP BY fs2.focus_id
)
WHEN '.self::PREFIX_STATS.'.type = "submission" THEN (
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "submission"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs2.lead_id = '.self::PREFIX_LEADS.'.id
GROUP BY fs2.focus_id
)
WHEN '.self::PREFIX_STATS.'.type = "click" THEN (
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "click"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs2.lead_id = '.self::PREFIX_LEADS.'.id
GROUP BY fs2.focus_id
)
END',
],
self::PREFIX_STATS.'.conversion_rate_click' => [
'label' => 'mautic.report.focus.ratio.click',
'type' => 'string',
'suffix' => ' %',
'alias' => 'conversion_rate_click',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "click" THEN (
SELECT
ROUND(
(
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "click"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs2.lead_id = '.self::PREFIX_LEADS.'.id
) * 100.0 /
NULLIF((
SELECT COUNT(fs3.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs3
WHERE fs3.type = "view"
AND fs3.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs3.lead_id = '.self::PREFIX_LEADS.'.id
), 0)
, 2)
)
ELSE NULL
END',
],
self::PREFIX_STATS.'.conversion_rate_submission' => [
'label' => 'mautic.report.focus.ratio.submission',
'type' => 'string',
'suffix' => ' %',
'alias' => 'conversion_rate_submission',
'formula' => 'CASE
WHEN '.self::PREFIX_STATS.'.type = "submission" THEN (
SELECT
ROUND(
(
SELECT COUNT(fs2.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs2
WHERE fs2.type = "submission"
AND fs2.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs2.lead_id = '.self::PREFIX_LEADS.'.id
) * 100.0 /
NULLIF((
SELECT COUNT(fs3.id)
FROM '.MAUTIC_TABLE_PREFIX.'focus_stats fs3
WHERE fs3.type = "view"
AND fs3.focus_id = '.self::PREFIX_STATS.'.focus_id
AND fs3.lead_id = '.self::PREFIX_LEADS.'.id
), 0)
, 2)
)
ELSE NULL
END',
],
];
$data = [
'display_name' => 'mautic.report.datasource.focus.leads',
'columns' => array_merge($columns, $columnsLeads, $event->getLeadColumns()),
];
$event->addTable(self::CONTEXT_FOCUS_LEADS, $data, self::FOCUS_GROUP);
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGenerate(ReportGeneratorEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_FOCUS_STATS, self::CONTEXT_FOCUS_LEADS])) {
return;
}
$queryBuilder = $event->getQueryBuilder();
$queryBuilder->from(MAUTIC_TABLE_PREFIX.'focus_stats', self::PREFIX_STATS)
->leftJoin(self::PREFIX_STATS, MAUTIC_TABLE_PREFIX.'focus', self::PREFIX_FOCUS,
self::PREFIX_FOCUS.'.id = '.self::PREFIX_STATS.'.focus_id')
->leftJoin(self::PREFIX_STATS, MAUTIC_TABLE_PREFIX.'channel_url_trackables', self::PREFIX_TRACKABLES,
self::PREFIX_TRACKABLES.'.channel_id = '.self::PREFIX_STATS.'.focus_id AND '.
self::PREFIX_TRACKABLES.'.channel = "focus"')
->leftJoin(self::PREFIX_STATS, MAUTIC_TABLE_PREFIX.'page_redirects', self::PREFIX_REDIRECTS,
self::PREFIX_REDIRECTS.'.id = '.self::PREFIX_TRACKABLES.'.redirect_id')
->orderBy(self::PREFIX_FOCUS.'.name', 'ASC')
->addOrderBy(self::PREFIX_STATS.'.type', 'ASC');
if ($event->hasColumn(self::PREFIX_FOCUS.'.category')) {
$queryBuilder->leftJoin(self::PREFIX_FOCUS, MAUTIC_TABLE_PREFIX.'categories', self::PREFIX_CATEGORIES,
self::PREFIX_FOCUS.'.category_id = '.self::PREFIX_CATEGORIES.'.id');
}
switch ($event->getContext()) {
case self::CONTEXT_FOCUS_LEADS:
$queryBuilder->leftJoin(self::PREFIX_FOCUS, MAUTIC_TABLE_PREFIX.'leads', self::PREFIX_LEADS,
self::PREFIX_STATS.'.lead_id = '.self::PREFIX_LEADS.'.id');
$queryBuilder->groupBy(
self::PREFIX_STATS.'.focus_id',
self::PREFIX_STATS.'.type',
self::PREFIX_STATS.'.lead_id'
);
break;
case self::CONTEXT_FOCUS_STATS:
$queryBuilder->groupBy(self::PREFIX_STATS.'.focus_id', self::PREFIX_STATS.'.type');
break;
}
$event->applyDateFilters($queryBuilder, 'date_added', self::PREFIX_STATS);
$event->setQueryBuilder($queryBuilder);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event\GlobalSearchEvent;
use Mautic\CoreBundle\Service\GlobalSearch;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private FocusModel $model,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
];
}
public function onGlobalSearch(GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->model,
'@MauticFocus/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.focus.header', $results);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Mautic\FormBundle\Event\SubmissionEvent;
use Mautic\FormBundle\FormEvents;
use Mautic\PageBundle\Event\PageHitEvent;
use Mautic\PageBundle\PageEvents;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class StatSubscriber implements EventSubscriberInterface
{
public function __construct(
private FocusModel $model,
private RequestStack $requestStack,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_HIT => ['onPageHit', 0],
FormEvents::FORM_ON_SUBMIT => ['onFormSubmit', 0],
];
}
public function onPageHit(PageHitEvent $event): void
{
$hit = $event->getHit();
$source = $hit->getSource();
if ('focus' == $source || 'focus.focus' == $source) {
$sourceId = $hit->getSourceId();
$focus = $this->model->getEntity($sourceId);
if ($focus && $focus->isPublished()) {
$this->model->addStat($focus, Stat::TYPE_CLICK, $hit, $hit->getLead());
}
}
}
/**
* Note if this submission is from a focus submit.
*/
public function onFormSubmit(SubmissionEvent $event): void
{
// Check the request for a focus field
$mauticform = $this->requestStack->getCurrentRequest()->request->all()['mauticform'] ?? [];
$id = $mauticform['focusId'] ?? false;
if (!empty($id)) {
$focus = $this->model->getEntity($id);
if ($focus && $focus->isPublished()) {
// Make sure the form is still applicable
$form = $event->getSubmission()->getForm();
if ((int) $form->getId() === (int) $focus->getForm()) {
$this->model->addStat($focus, Stat::TYPE_FORM, $event->getSubmission(), $event->getLead());
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace MauticPlugin\MauticFocusBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
$this->addContactRestrictedRepositories([Stat::class]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace MauticPlugin\MauticFocusBundle;
class FocusEventTypes
{
/**
* The focus.on_open event type is used for event dispatched when an focus is opened.
*
* @var string
*/
public const FOCUS_ON_VIEW = 'focus.on_view';
/**
* The focus.on_click event type is used for event dispatched when an focus is clicked.
*
* @var string
*/
public const FOCUS_ON_CLICK = 'focus.on_click';
}

View File

@@ -0,0 +1,74 @@
<?php
namespace MauticPlugin\MauticFocusBundle;
/**
* Events available for MauticFocusBundle.
*/
final class FocusEvents
{
/**
* The mautic.focus_pre_save event is dispatched right before a focus is persisted.
*
* The event listener receives a MauticPlugin\MauticFocusBundle\Event\FocusEvent instance.
*
* @var string
*/
public const PRE_SAVE = 'mautic.focus_pre_save';
/**
* The mautic.focus_post_save event is dispatched right after a focus is persisted.
*
* The event listener receives a MauticPlugin\MauticFocusBundle\Event\FocusEvent instance.
*
* @var string
*/
public const POST_SAVE = 'mautic.focus_post_save';
/**
* The mautic.focus_pre_delete event is dispatched before a focus is deleted.
*
* The event listener receives a MauticPlugin\MauticFocusBundle\Event\FocusEvent instance.
*
* @var string
*/
public const PRE_DELETE = 'mautic.focus_pre_delete';
/**
* The mautic.focus_post_delete event is dispatched after a focus is deleted.
*
* The event listener receives a MauticPlugin\MauticFocusBundle\Event\FocusEvent instance.
*
* @var string
*/
public const POST_DELETE = 'mautic.focus_post_delete';
/**
* The mautic.focus_token_replacent event is dispatched after a load content.
*
* The event listener receives a MauticPlugin\MauticFocusBundle\Event\FocusEvent instance.
*
* @var string
*/
public const TOKEN_REPLACEMENT = 'mautic.focus_token_replacement';
/**
* The mautic.focus.on_campaign_trigger_action event is fired when the campaign action triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_ACTION = 'mautic.focus.on_campaign_trigger_action';
/**
* The mautic.focus.on_open event is dispatched when an focus is opened.
*
* The event listener receives a
* MauticPlugin\MauticFocusBundle\Event\FocusOpenEvent instance.
*
* @var string
*/
public const FOCUS_ON_VIEW = 'mautic.focus.on_view';
}

View File

@@ -0,0 +1,81 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<string, mixed>>
*/
class ColorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'primary',
TextType::class,
[
'label' => 'mautic.focus.form.primary_color',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-toggle' => 'color',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
]
);
$builder->add(
'text',
TextType::class,
[
'label' => 'mautic.focus.form.text_color',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-toggle' => 'color',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
]
);
$builder->add(
'button',
TextType::class,
[
'label' => 'mautic.focus.form.button_color',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-toggle' => 'color',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
]
);
$builder->add(
'button_text',
TextType::class,
[
'label' => 'mautic.focus.form.button_text_color',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-toggle' => 'color',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'focus_color';
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<string, mixed>>
*/
class ContentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'headline',
TextType::class,
[
'label' => 'mautic.focus.form.headline',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onblur' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
'required' => false,
]
);
$builder->add(
'tagline',
TextType::class,
[
'label' => 'mautic.focus.form.tagline',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.core.optional',
'onblur' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
'required' => false,
]
);
$builder->add(
'link_text',
TextType::class,
[
'label' => 'mautic.focus.form.link_text',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onblur' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
]
);
$builder->add(
'link_url',
TextType::class,
[
'label' => 'mautic.focus.form.link_url',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onblur' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
]
);
$builder->add(
'link_new_window',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.link_new_window',
'data' => $options['link_new_window'] ?? true,
'attr' => [
'onchange' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
]
);
$builder->add(
'font',
ChoiceType::class,
[
'choices' => [
'Arial' => 'Arial, Helvetica, sans-serif',
'Arial Black' => '\'Arial Black\', Gadget, sans-serif',
'Arial Narrow' => '\'Arial Narrow\', sans-serif',
'Century Gothic' => 'Century Gothic, sans-serif',
'Copperplate Gothic Light' => 'Copperplate / Copperplate Gothic Light, sans-serif',
'Courier New' => '\'Courier New\', Courier, monospace',
'Georgia' => 'Georgia, Serif',
'Impact' => 'Impact, Charcoal, sans-serif',
'Lucida Console' => '\'Lucida Console\', Monaco, monospace',
'Lucida Sans Unicode' => '\'Lucida Sans Unicode\', \'Lucida Grande\', sans-serif',
'Palatino' => '\'Palatino Linotype\', \'Book Antiqua\', Palatino, serif',
'Tahoma' => 'Tahoma, Geneva, sans-serif',
'Times New Roman' => '\'Times New Roman\', Times, serif',
'Trebuchet MS' => '\'Trebuchet MS\', Helvetica, sans-serif',
'Verdana' => 'Verdana, Geneva, sans-serif',
],
'label' => 'mautic.focus.form.font',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.focusUpdatePreview()',
'data-show-on' => '{"focus_html_mode_0":"checked"}',
],
'required' => false,
'placeholder' => false,
]
);
$builder->add(
'css',
TextareaType::class,
[
'label' => 'mautic.focus.form.custom.css',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'rows' => 6,
'onchange' => 'Mautic.focusUpdatePreview()',
'tooltip' => 'mautic.focus.form.custom.css.help',
],
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'focus_content';
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'label' => false,
]
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<string, mixed>|null>
*/
class FocusListType extends AbstractType
{
private $repo;
public function __construct(
protected FocusModel $focusModel,
) {
$this->repo = $this->focusModel->getRepository();
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'choices' => function (Options $options): array {
$choices = [];
$list = $this->repo->getFocusList($options['data']);
foreach ($list as $row) {
$choices[$row['name']] = $row['id'];
}
// sort by language
ksort($choices, SORT_NATURAL);
return $choices;
},
'expanded' => false,
'multiple' => true,
'required' => false,
'placeholder' => fn (Options $options): string => (empty($options['choices'])) ? 'mautic.focus.no.focusitem.note' : 'mautic.core.form.chooseone',
'disabled' => fn (Options $options): bool => empty($options['choices']),
'top_level' => 'variant',
'variant_parent' => null,
'ignore_ids' => [],
]
);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<string, mixed>>
*/
class FocusPropertiesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [];
// Type specific
switch ($options['focus_style']) {
case 'bar':
$builder->add(
'allow_hide',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.bar.allow_hide',
'data' => $options['data']['allow_hide'] ?? true,
'attr' => [
'onchange' => 'Mautic.focusUpdatePreview()',
],
]
);
$builder->add(
'push_page',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.bar.push_page',
'attr' => [
'tooltip' => 'mautic.focus.form.bar.push_page.tooltip',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'data' => $options['data']['push_page'] ?? true,
]
);
$builder->add(
'sticky',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.bar.sticky',
'attr' => [
'tooltip' => 'mautic.focus.form.bar.sticky.tooltip',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'data' => $options['data']['sticky'] ?? true,
]
);
$builder->add(
'size',
ChoiceType::class,
[
'choices' => [
'mautic.focus.form.bar.size.large' => 'large',
'mautic.focus.form.bar.size.regular' => 'regular',
],
'label' => 'mautic.focus.form.bar.size',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
'placeholder' => false,
]
);
$choices = [
'mautic.focus.form.placement.top' => 'top',
'mautic.focus.form.placement.bottom' => 'bottom',
];
break;
case 'modal':
$choices = [
'mautic.focus.form.placement.top' => 'top',
'mautic.focus.form.placement.middle' => 'middle',
'mautic.focus.form.placement.bottom' => 'bottom',
];
break;
case 'notification':
$choices = [
'mautic.focus.form.placement.top_left' => 'top_left',
'mautic.focus.form.placement.top_right' => 'top_right',
'mautic.focus.form.placement.bottom_left' => 'bottom_left',
'mautic.focus.form.placement.bottom_right' => 'bottom_right',
];
break;
case 'page':
break;
}
if (!empty($choices)) {
$builder->add(
'placement',
ChoiceType::class,
[
'choices' => $choices,
'label' => 'mautic.focus.form.placement',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.focusUpdatePreview()',
'tooltip' => 'mautic.focus.form.placement.help',
],
'required' => false,
'placeholder' => false,
]
);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['focus_style']);
$resolver->setDefaults(
[
'label' => false,
]
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array<string, mixed>>
*/
class FocusShowType extends AbstractType
{
public function __construct(
protected RouterInterface $router,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'focus',
FocusListType::class,
[
'label' => 'mautic.focus.focusitem.selectitem',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.focus.focusitem.selectitem_descr',
'onchange' => 'Mautic.disabledFocusActions()',
],
'multiple' => false,
'required' => true,
'constraints' => [
new NotBlank(
['message' => 'mautic.focus.choosefocus.notblank']
),
],
'data' => $options['data']['focus'] ?? null,
]
);
if (!empty($options['update_select'])) {
$windowUrl = $this->router->generate(
'mautic_focus_action',
[
'objectAction' => 'new',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'newFocusButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow({
"windowUrl": "'.$windowUrl.'"
})',
'icon' => 'ri-add-line',
],
'label' => 'mautic.focus.show.new.item',
]
);
// create button edit focus
$windowUrlEdit = $this->router->generate(
'mautic_focus_action',
[
'objectAction' => 'edit',
'objectId' => 'focusId',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'editFocusButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow(Mautic.standardFocusUrl({"windowUrl": "'.$windowUrlEdit.'"}))',
'disabled' => !isset($options['data']['focus']),
'icon' => 'ri-edit-line',
],
'label' => 'mautic.focus.show.edit.item',
]
);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['update_select']);
}
public function getBlockPrefix(): string
{
return 'focusshow_list';
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\ButtonGroupType;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Form\Type\EmailUtmTagsType;
use Mautic\FormBundle\Form\Type\FormListType;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<Focus>
*/
class FocusType extends AbstractType
{
public function __construct(
private CorePermissions $security,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['website' => 'url', 'html' => 'html', 'editor' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('focus', $options));
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$builder->add(
'utmTags',
EmailUtmTagsType::class,
[
'label' => 'mautic.email.utm_tags',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.utm_tags.tooltip',
],
'required' => false,
]
);
$builder->add(
'html_mode',
ButtonGroupType::class,
[
'label' => 'mautic.focus.form.html_mode',
'label_attr' => ['class' => 'control-label'],
'data' => !empty($options['data']->getHtmlMode()) ? $options['data']->getHtmlMode() : 'basic',
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.focusUpdatePreview()',
'tooltip' => 'mautic.focums.html_mode.tooltip',
],
'choices' => [
'mautic.focus.form.basic' => 'basic',
'mautic.focus.form.editor' => 'editor',
'mautic.focus.form.html' => 'html',
],
]
);
$builder->add(
'editor',
TextareaType::class,
[
'label' => 'mautic.focus.form.editor',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control editor editor-basic',
'data-show-on' => '{"focus_html_mode_1":"checked"}',
],
'required' => false,
]
);
$builder->add(
'html',
TextareaType::class,
[
'label' => 'mautic.focus.form.html',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'rows' => 12,
'data-show-on' => '{"focus_html_mode_2":"checked"}',
'onchange' => 'Mautic.focusUpdatePreview()',
],
'required' => false,
]
);
$builder->add(
'website',
UrlType::class,
[
'label' => 'mautic.focus.form.website',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.focus.form.website.tooltip',
],
'required' => false,
]
);
// add category
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'plugin:focus',
]
);
$builder->add('projects', ProjectType::class);
if (!empty($options['data']) && $options['data']->getId()) {
$readonly = !$this->security->isGranted('focus:items:publish');
$data = $options['data']->isPublished(false);
} elseif (!$this->security->isGranted('focus:items:publish')) {
$readonly = true;
$data = false;
} else {
$readonly = false;
$data = false;
}
$builder->add(
'isPublished',
YesNoButtonGroupType::class,
[
'data' => $data,
'attr' => [
'readonly' => $readonly,
],
]
);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add('properties', PropertiesType::class, ['data' => $options['data']->getProperties()]);
// Will be managed by JS
$builder->add('type', HiddenType::class);
$builder->add('style', HiddenType::class);
$builder->add(
'form',
FormListType::class,
[
'label' => 'mautic.focus.form.choose_form',
'multiple' => false,
'placeholder' => '',
'attr' => [
'onchange' => 'Mautic.focusUpdatePreview()',
],
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
$customButtons = [
[
'name' => 'builder',
'label' => 'mautic.core.builder',
'attr' => [
'class' => 'btn btn-tertiary btn-dnd btn-nospin',
'icon' => 'ri-layout-line',
'onclick' => 'Mautic.launchFocusBuilder();',
],
],
];
if (!empty($options['update_select'])) {
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'pre_extra_buttons' => $customButtons,
]
);
$builder->add(
'updateSelect',
HiddenType::class,
[
'data' => $options['update_select'],
'mapped' => false,
]
);
} else {
$builder->add(
'buttons',
FormButtonsType::class,
[
'pre_extra_buttons' => $customButtons,
]
);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => Focus::class,
]
);
$resolver->setDefined(['update_select']);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<string, mixed>>
*/
class PropertiesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'bar',
FocusPropertiesType::class,
[
'focus_style' => 'bar',
'data' => $options['data']['bar'] ?? [],
]
);
$builder->add(
'modal',
FocusPropertiesType::class,
[
'focus_style' => 'modal',
'data' => $options['data']['modal'] ?? [],
]
);
$builder->add(
'notification',
FocusPropertiesType::class,
[
'focus_style' => 'notification',
'data' => $options['data']['notification'] ?? [],
]
);
$builder->add(
'page',
FocusPropertiesType::class,
[
'focus_style' => 'page',
'data' => $options['data']['page'] ?? [],
]
);
$builder->add(
'animate',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.animate',
'data' => $options['data']['animate'] ?? true,
'attr' => [
'onchange' => 'Mautic.focusUpdatePreview()',
],
]
);
$builder->add(
'link_activation',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.activate_for_links',
'data' => $options['data']['link_activation'] ?? true,
'attr' => [
'data-show-on' => '{"focus_properties_when": ["leave"]}',
],
]
);
$builder->add(
'colors',
ColorType::class,
[
'label' => false,
]
);
$builder->add(
'content',
ContentType::class,
[
'label' => false,
]
);
$builder->add(
'when',
ChoiceType::class,
[
'choices' => [
'mautic.focus.form.when.immediately' => 'immediately',
'mautic.focus.form.when.scroll_slight' => 'scroll_slight',
'mautic.focus.form.when.scroll_middle' => 'scroll_middle',
'mautic.focus.form.when.scroll_bottom' => 'scroll_bottom',
'mautic.focus.form.when.leave' => 'leave',
],
'label' => 'mautic.focus.form.when',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'expanded' => false,
'multiple' => false,
'required' => false,
'placeholder' => false,
]
);
$builder->add(
'timeout',
TextType::class,
[
'label' => 'mautic.focus.form.timeout',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'postaddon_text' => 'sec',
],
'required' => false,
]
);
$builder->add(
'frequency',
ChoiceType::class,
[
'choices' => [
'mautic.focus.form.frequency.everypage' => 'everypage',
'mautic.focus.form.frequency.once' => 'once',
'mautic.focus.form.frequency.q2m' => 'q2min',
'mautic.focus.form.frequency.q15m' => 'q15min',
'mautic.focus.form.frequency.hourly' => 'hourly',
'mautic.focus.form.frequency.daily' => 'daily',
],
'label' => 'mautic.focus.form.frequency',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control', 'tooltip' => 'mautic.focus.form.frequency.help'],
'expanded' => false,
'multiple' => false,
'required' => false,
'placeholder' => false,
]
);
$builder->add(
'stop_after_conversion',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.engage_after_conversion',
'data' => $options['data']['stop_after_conversion'] ?? true,
'attr' => [
'tooltip' => 'mautic.focus.form.engage_after_conversion.tooltip',
],
]
);
$builder->add(
'stop_after_close',
YesNoButtonGroupType::class,
[
'label' => 'mautic.focus.form.stop_after_close',
'data' => (isset($options['data']['stop_after_close'])) ? $options['data']['stop_after_close'] : false,
'attr' => [
'tooltip' => 'mautic.focus.form.stop_after_close.tooltip',
],
]
);
}
public function getBlockPrefix(): string
{
return 'focus_entity_properties';
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'label' => false,
]
);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Helper;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Check if URL can be displayed via IFRAME.
*/
class IframeAvailabilityChecker
{
public function __construct(
private TranslatorInterface $translator,
) {
}
public function check(string $url, string $currentScheme): JsonResponse
{
$response = new JsonResponse();
$responseContent = [
'status' => 0,
'errorMessage' => '',
];
if ($this->checkProtocolMismatch($url, $currentScheme)) {
$responseContent['errorMessage'] = $this->translator->trans(
'mautic.focus.protocol.mismatch',
[
'%url%' => str_replace('http://', 'https://', $url),
]);
} else {
$client = HttpClient::create([
'headers' => [
'User-Agent' => 'Mautic',
],
]);
try {
/** @var ResponseInterface $httpResponse */
$httpResponse = $client->request(Request::METHOD_GET, $url);
$blockingHeader = $this->checkHeaders($httpResponse->getHeaders(false));
if ('' !== $blockingHeader) {
$responseContent['errorMessage'] = $this->translator->trans(
'mautic.focus.blocking.iframe.header',
[
'%url%' => $url,
'%header%' => $blockingHeader,
]
);
}
} catch (\Exception $e) {
// Transport exception with SSL cert for example
$responseContent['errorMessage'] = $e->getMessage();
}
}
if ('' === $responseContent['errorMessage'] && Response::HTTP_OK === $httpResponse->getStatusCode()) {
$responseContent['status'] = 1;
}
$response->setData($responseContent);
return $response;
}
/**
* Iframe doesn't allow cross protocol requests.
*/
private function checkProtocolMismatch(string $url, string $currentScheme): bool
{
// Mixed Content: The page at 'https://example.com' was loaded over HTTPS,
// but requested an insecure frame 'http://target-example.com/'. This request has been blocked; the content
// must be served over HTTPS.
return 'https' === $currentScheme && str_starts_with($url, 'http://');
}
/**
* @param array $headers Content of Symfony\Contracts\HttpClient\ResponseInterface::getHeaders()
*
* @return string Blocking header if problem found
*/
private function checkHeaders(array $headers): string
{
$return = '';
if ($this->headerContains($headers, 'x-frame-options')) {
// @see https://stackoverflow.com/questions/31944552/iframe-refuses-to-display
$return = 'x-frame-options: SAMEORIGIN';
}
if ($this->headerContains($headers, 'content-security-policy', "frame-ancestors 'self'")) {
// https://seznam.cz
// Refused to display 'https://www.seznam.cz/' in a frame because an ancestor violates the following
// Content Security Policy directive: "frame-ancestors 'self'".
// @see https://stackoverflow.com/questions/31944552/iframe-refuses-to-display
$return = 'content-security-policy';
}
return $return;
}
private function headerContains(array $headers, string $name, ?string $content = null): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
if (array_key_exists($name, $headers)) {
if (null !== $content) {
if (str_starts_with($headers[$name][0], $content)) {
return true;
} else {
return false;
}
}
return true;
}
return false;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Helper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
class TokenHelper
{
public const REGEX = '/{focus=(.*?)}/i';
public function __construct(
protected FocusModel $model,
protected RouterInterface $router,
protected CorePermissions $security,
) {
}
public function findFocusTokens($content): array
{
preg_match_all(self::REGEX, $content, $matches);
$tokens = [];
if (count($matches[0])) {
foreach ($matches[1] as $id) {
$token = '{focus='.$id.'}';
$focus = $this->model->getEntity((int) $id);
if (null !== $focus
&& (
$focus->isPublished()
|| $this->security->hasEntityAccess(
'focus:items:viewown',
'focus:items:viewother',
$focus->getCreatedBy()
)
)
) {
$script = '<script src="'.
$this->router->generate(
'mautic_focus_generate',
['id' => $id],
UrlGeneratorInterface::ABSOLUTE_URL
).
'" type="text/javascript" charset="utf-8" async="async"></script>';
$tokens[$token] = $script;
} else {
$tokens[$token] = '';
}
}
}
return $tokens;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace MauticPlugin\MauticFocusBundle;
use Mautic\PluginBundle\Bundle\PluginBundleBase;
class MauticFocusBundle extends PluginBundleBase
{
}

View File

@@ -0,0 +1,432 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Model;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\EntityManagerInterface;
use MatthiasMullie\Minify;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\FormBundle\Entity\Submission;
use Mautic\FormBundle\ProgressiveProfiling\DisplayManager;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Model\TrackableModel;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Event\FocusEvent;
use MauticPlugin\MauticFocusBundle\FocusEvents;
use MauticPlugin\MauticFocusBundle\Form\Type\FocusType;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
use Twig\Environment;
use Twig\Runtime\EscaperRuntime;
/**
* @extends FormModel<Focus>
*/
class FocusModel extends FormModel implements GlobalSearchInterface
{
public function __construct(
protected \Mautic\FormBundle\Model\FormModel $formModel,
protected TrackableModel $trackableModel,
protected Environment $twig,
protected FieldModel $leadFieldModel,
protected ContactTracker $contactTracker,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
$this->dispatcher = $dispatcher;
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getActionRouteBase(): string
{
return 'focus';
}
public function getPermissionBase(): string
{
return 'focus:items';
}
/**
* @param object $entity
* @param string|null $action
* @param array $options
*
* @throws NotFoundHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Focus) {
throw new MethodNotAllowedHttpException(['Focus']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(FocusType::class, $entity, $options);
}
/**
* @return \MauticPlugin\MauticFocusBundle\Entity\FocusRepository
*/
public function getRepository()
{
return $this->em->getRepository(Focus::class);
}
/**
* @return \MauticPlugin\MauticFocusBundle\Entity\StatRepository
*/
public function getStatRepository()
{
return $this->em->getRepository(Stat::class);
}
/**
* @param int|null $id
*/
public function getEntity($id = null): ?Focus
{
if (null === $id) {
return new Focus();
}
return parent::getEntity($id);
}
/**
* @param Focus $entity
* @param bool|false $unlock
*/
public function saveEntity($entity, $unlock = true): void
{
parent::saveEntity($entity, $unlock);
$this->generateTrackableUrl($entity);
}
/**
* @param bool $isPreview
*/
public function generateJavascript(Focus $focus, $isPreview = false): string
{
$lead = $this->contactTracker->getContact();
$focusArray = $focus->toArray();
$url = '';
if ($trackableUrl = $this->generateTrackableUrl($focus, $lead)) {
$url = '{focusClickUrl}';
}
$javascript = $this->twig->render(
'@MauticFocus/Builder/generate.js.twig',
[
'focus' => $focus,
'preview' => $isPreview,
'clickUrl' => $url,
]
);
$content = $this->getContent($focusArray, $isPreview, $url);
$data = [
'js' => (new Minify\JS($javascript))->minify(),
'focus' => InputHelper::minifyHTML($content['focus']),
'form' => InputHelper::minifyHTML($content['form']),
];
// Replace tokens to ensure clickthroughs, lead tokens etc. are appropriate
$tokenEvent = new TokenReplacementEvent($data['focus'], $lead, ['focus_id' => $focus->getId()]);
if ($trackableUrl) {
$tokenEvent->addToken($url, $trackableUrl);
}
$this->dispatcher->dispatch($tokenEvent, FocusEvents::TOKEN_REPLACEMENT);
$focusContent = $tokenEvent->getContent();
$focusContent = str_replace('{focus_form}', $data['form'], $focusContent, $formReplaced);
if (!$formReplaced && !empty($data['form'])) {
// Form token missing so just append the form
$focusContent .= $data['form'];
}
$focusContent = $this->twig->getRuntime(EscaperRuntime::class)->escape($focusContent, 'js');
return str_replace('{focus_content}', $focusContent, $data['js']);
}
/**
* @param bool $isPreview
* @param string $url
*
* @return array
*/
public function getContent(array $focus, $isPreview = false, $url = '#')
{
$form = (!empty($focus['form']) && 'form' === $focus['type']) ? $this->formModel->getEntity($focus['form']) : null;
if (isset($focus['html_mode'])) {
$htmlMode = $focus['htmlMode'] = $focus['html_mode'];
} elseif (isset($focus['htmlMode'])) {
$htmlMode = $focus['htmlMode'];
} else {
$htmlMode = 'basic';
}
if (isset($focus[$htmlMode])) {
$focus[$htmlMode] = htmlspecialchars_decode($focus[$htmlMode]);
}
$content = $this->twig->render(
'@MauticFocus/Builder/content.html.twig',
[
'focus' => $focus,
'preview' => $isPreview,
'htmlMode' => $htmlMode,
'clickUrl' => $url,
]
);
// Form has to be generated outside of the content or else the form src
// will be converted to clickables
$fields = $form ? $form->getFields()->toArray() : [];
[$pages, $lastPage] = $this->formModel->getPages($fields);
$displayManager = $viewOnlyFields = null;
if ($form) {
$viewOnlyFields = $this->formModel->getCustomComponents()['viewOnlyFields'];
$displayManager = new DisplayManager($form, !empty($viewOnlyFields) ? $viewOnlyFields : []);
}
$formContent = (!empty($form)) ? $this->twig->render(
'@MauticFocus/Builder/form.html.twig',
[
'form' => $form,
'pages' => $pages,
'lastPage' => $lastPage,
'style' => $focus['style'],
'focusId' => $focus['id'],
'preview' => $isPreview,
'contactFields' => $this->leadFieldModel->getFieldListWithProperties(),
'companyFields' => $this->leadFieldModel->getFieldListWithProperties('company'),
'viewOnlyFields' => $viewOnlyFields,
'displayManager' => $displayManager,
]
) : '';
if ($isPreview) {
$content = str_replace('{focus_form}', $formContent, $content, $formReplaced);
if (!$formReplaced && !empty($formContent)) {
$content .= $formContent;
}
return $content;
}
return [
'focus' => $content,
'form' => $formContent,
];
}
/**
* Get whether the color is light or dark.
*/
public static function isLightColor($hex, $level = 200): bool
{
$hex = str_replace('#', '', $hex);
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
$compareWith = ((($r * 299) + ($g * 587) + ($b * 114)) / 1000);
return $compareWith >= $level;
}
/**
* Add a stat entry.
*
* @param mixed $type
* @param mixed $data
* @param array<int|string|array<int|string>>|Lead|Submission|null $lead
*/
public function addStat(Focus $focus, $type, $data = null, $lead = null): ?Stat
{
if (empty($lead)) {
return null;
}
if ($lead instanceof Lead && !$lead->getId()) {
return null;
}
if (is_array($lead)) {
if (empty($lead['id'])) {
return null;
}
$lead = $this->em->getReference(Lead::class, $lead['id']);
}
switch ($type) {
case Stat::TYPE_FORM:
case Stat::TYPE_CLICK:
/** @var \Mautic\PageBundle\Entity\Hit|Submission $data */
$typeId = $data->getId();
break;
case Stat::TYPE_NOTIFICATION:
$typeId = null;
break;
}
$stat = new Stat();
$stat->setFocus($focus)
->setDateAdded(new \DateTime())
->setType($type)
->setTypeId($typeId)
->setLead($lead);
$this->getStatRepository()->saveEntity($stat);
return $stat;
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Focus) {
throw new MethodNotAllowedHttpException(['Focus']);
}
switch ($action) {
case 'pre_save':
$name = FocusEvents::PRE_SAVE;
break;
case 'post_save':
$name = FocusEvents::POST_SAVE;
break;
case 'pre_delete':
$name = FocusEvents::PRE_DELETE;
break;
case 'post_delete':
$name = FocusEvents::POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new FocusEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* @param bool $canViewOthers
*/
public function getStats(Focus $focus, $unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $canViewOthers = true): array
{
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo, $unit);
$q = $query->prepareTimeDataQuery('focus_stats', 'date_added', ['type' => Stat::TYPE_NOTIFICATION, 'focus_id' => $focus->getId()]);
if (!$canViewOthers) {
$this->limitQueryToCreator($q);
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.focus.graph.views'), $data);
if ('notification' != $focus->getType()) {
if ('link' == $focus->getType()) {
$q = $query->prepareTimeDataQuery('focus_stats', 'date_added', ['type' => Stat::TYPE_CLICK, 'focus_id' => $focus->getId()]);
if (!$canViewOthers) {
$this->limitQueryToCreator($q);
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.focus.graph.clicks'), $data);
} else {
$q = $query->prepareTimeDataQuery('focus_stats', 'date_added', ['type' => Stat::TYPE_FORM, 'focus_id' => $focus->getId()]);
if (!$canViewOthers) {
$this->limitQueryToCreator($q);
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.focus.graph.submissions'), $data);
}
}
return $chart->render();
}
/**
* Joins the email table and limits created_by to currently logged in user.
*/
public function limitQueryToCreator(QueryBuilder $q): void
{
$q->join('t', MAUTIC_TABLE_PREFIX.'focus', 'm', 'e.id = t.focus_id')
->andWhere('m.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
public function getViewsCount(Focus $focus): int
{
return $this->getStatRepository()->getViewsCount($focus->getId());
}
public function getUniqueViewsCount(Focus $focus): int
{
return $this->getStatRepository()->getUniqueViewsCount($focus->getId());
}
public function getClickThroughCount(Focus $focus): int
{
return $this->getStatRepository()->getClickThroughCount($focus->getId());
}
private function generateTrackableUrl(Focus $focus, ?Lead $lead = null): ?string
{
$focusArray = $focus->toArray();
if ('link' != $focusArray['type'] || !($linkUrl = $focusArray['properties']['content']['link_url'])) {
return null;
}
return $this->trackableModel->generateTrackableUrl(
$this->trackableModel->getTrackableByUrl($linkUrl, 'focus', $focus->getId()),
[
'channel' => ['focus', $focus->getId()],
'lead' => $lead ? $lead->getId() : null,
],
false,
$focus->getUtmTags()
);
}
}

View File

@@ -0,0 +1,5 @@
# Mautic bundle for Focus Items plugin
## This plugin is managed centrally in https://github.com/mautic/mautic/blob/head/plugins/MauticFocusBundle and this is a read-only mirror repository.
**📣 Please make PRs and issues against Mautic Core, not here!**

View File

@@ -0,0 +1,8 @@
.barAnimate() {
-webkit-transition-property: margin;
transition-property: margin;
-webkit-transition-duration: .5s;
transition-duration: .5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}

View File

@@ -0,0 +1,65 @@
.mf-bar-collapser {
position: absolute;
right: 3px;
width: 24px;
height: 24px;
text-align: center;
z-index: 21000;
&.mf-bar-collapser-top {
top: 0;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
.mf-bar-collapser-icon svg {
margin: 2px 0 0 0;
}
}
&.mf-bar-collapser-bottom {
bottom: 0;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
.mf-bar-collapser-icon svg {
margin: -2px 0 0 0;
}
}
&.mf-bar-collapser-large {
width: 40px;
height: 40px;
&.mf-bar-collapser-top .mf-bar-collapser-icon svg {
margin: 4px 0 0 0;
}
&.mf-bar-collapser-bottom .mf-bar-collapser-icon svg {
margin: -4px 0 0 0;
}
}
&.mf-bar-collapser-sticky {
position: fixed;
}
&.mf-bar-collapser-top, &.mf-bar-collapser-bottom {
&.mf-bar-collapsed .mf-bar-collapser-icon svg {
margin: 0;
}
}
a.mf-bar-collapser-icon {
position: relative;
display: inline-block;
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
}

View File

@@ -0,0 +1,65 @@
{% set props = focus.properties %}
{% set color = (props.colors.primary is not empty and props.colors.primary is color light) ? '000000' : 'ffffff' %}
{% set animate = preview is not empty and props.animate is not empty ? ' mf-animate' : '' %}
<div class="mautic-focus mf-bar mf-bar-{{ props.bar.size }} mf-bar-{{ props.bar.placement }} {% if props.bar.sticky %}mf-bar-sticky{% endif %} {{ animate }}" style="background-color: #{{ props.colors.primary|replace({'#': ''}) }};">
<div class="mf-content">
{% if htmlMode in ['editor', 'html'] %}
{{ focus[htmlMode]|raw }}
{% else %}
<div class="mf-headline">{{ props.content.headline }}</div>
{% if 'form' == focus.type %}
{focus_form}
{% elseif 'link' == focus.type %}
<a href="{% if preview is empty %}{{ clickUrl }}{% else %}#{% endif %}" class="mf-link" target="{% if props.content.link_new_window %}_new{% else %}_parent{% endif %}">
{{ props.content.link_text }}
</a>
{% endif %}
{% endif %}
</div>
<div class="mf-bar-collapse"></div>
</div>
{% if props.bar.allow_hide %}
<div class="mf-copy-to-parent mf-bar-collapser mf-bar-collapser-{{ props.bar.placement }} mf-bar-collapser-{{ props.bar.size }} {% if props.bar.sticky %}mf-bar-collapser-sticky{% endif %} mf-bar-collapser-{{ focus.id }}"
style="background-color: #{{ props.colors.primary|replace({'#': ''}) }}; color: #{{ props.colors.text|replace({'#': ''}) }};">
<style scoped>
.mf-bar-collapser-icon {
color: #{{ color }};
}
.mf-bar-collapser-icon:hover {
color: #{{ color }};
}
</style>
<a class="mf-bar-collapser-icon" href="javascript:void(0)" {% if preview is not empty %}onclick="Mautic.toggleBarCollapse()"{% endif %}>
{% set size = 'large' == props.bar.size ? 40 : 24 %}
{% set transformSize = 20 %}
{% set scale = 'large' == props.bar.size ? 1 : 0.6 %}
{% set direction = 'top' == props.bar.placement ? '-90' : '90' %}
<svg style="overflow: hidden;" xmlns="http://www.w3.org/2000/svg" width="{{ size }}" version="1.1"
height="{{ size }}" data-transform-size="{{ transformSize }}" data-transform-direction="{{ direction }}" data-transform-scale="{{ scale }}">
<g transform="scale({{ scale }}) rotate({{ direction }} {{ transformSize }} {{ transformSize }})">
<desc>Created with Raphaël 2.1.2</desc>
<defs>
<linearGradient gradientTransform="matrix(1,0,0,1,-4,-4)" y2="0" x2="6.123233995736766e-17" y1="1" x1="0" id="1390-_0050af-_002c62">
<stop stop-color="#{{ color }}" offset="0%"></stop>
<stop stop-color="#{{ color }}" offset="100%"></stop>
</linearGradient>
</defs>
<path transform="matrix(1,0,0,1,4,4)" opacity="0" stroke-linejoin="round" stroke-width="3"
d="M16,1.466C7.973,1.466,1.466,7.973,1.466,16C1.466,24.027,7.973,30.534,16,30.534C24.027,30.534,30.534,24.027,30.534,15.999999999999998C30.534,7.973,24.027,1.466,16,1.466ZM13.665,25.725L10.129,22.186L16.316,15.998999999999999L10.128999999999998,9.811999999999998L13.664999999999997,6.275999999999998L23.388999999999996,15.998999999999999L13.665,25.725Z"
stroke="#ffffff" fill="none" style="stroke-linejoin: round; opacity: 0;"></path>
<path fill-opacity="1" opacity="1" transform="matrix(1,0,0,1,4,4)"
d="M16,1.466C7.973,1.466,1.466,7.973,1.466,16C1.466,24.027,7.973,30.534,16,30.534C24.027,30.534,30.534,24.027,30.534,15.999999999999998C30.534,7.973,24.027,1.466,16,1.466ZM13.665,25.725L10.129,22.186L16.316,15.998999999999999L10.128999999999998,9.811999999999998L13.664999999999997,6.275999999999998L23.388999999999996,15.998999999999999L13.665,25.725Z"
stroke="none" fill="url(#1390-_0050af-_002c62)" style="opacity: 1; fill-opacity: 1;"></path>
<rect opacity="0" style="opacity: 0;" stroke="#000" fill="#000000" ry="0" rx="0" r="0" y="0" x="0"></rect>
</g>
</svg>
</a>
</div>
{% endif %}
{% if props.bar.push_page and 'top' == props.bar.placement %}
<div class="mf-move-to-parent mf-bar-spacer mf-bar-spacer-{{ props.bar.size }} mf-bar-spacer-{{ focus.id }}"></div>
{% endif %}

View File

@@ -0,0 +1,64 @@
{{ include('@MauticFocus/Builder/Bar/animations.less.twig', with_context=false) }}
.mf-bar-iframe {
width: 100%;
position: static;
z-index: 20000;
left: 0;
right: 0;
&.mf-animate {
.barAnimate();
}
&.mf-bar-iframe-top {
top: 0;
margin-top: -100px;
}
&.mf-bar-iframe-bottom {
bottom: 0;
margin-bottom: -100px;
}
&.mf-bar-iframe-regular {
body, html {
min-height: 30px;
}
&.mf-bar-iframe-top {
margin-top: -30px;
}
&.mf-bar-iframe-bottom {
margin-bottom: -30px;
}
}
&.mf-bar-iframe-large {
body, html {
min-height: 50px;
}
&.mf-bar-iframe-top {
margin-top: -50px;
}
&.mf-bar-iframe-bottom {
margin-bottom: -50px;
}
}
&.mf-bar-iframe-sticky {
position: fixed;
}
}
{{ include('@MauticFocus/Builder/Bar/shared.less.twig', with_context=false) }}
{{ include('@MauticFocus/Builder/Bar/collapser.less.twig', with_context=false) }}
@media only screen and (max-width: 667px) {
.mf-bar-collapser {
display: none !important;
}
}

View File

@@ -0,0 +1,26 @@
.mf-bar-spacer {
display: block;
overflow: hidden;
position: relative;
&.mf-bar-spacer-regular {
height: 30px;
}
&.mf-bar-spacer-large {
height: 50px;
}
}
.mf-bar-collapser-icon {
opacity: 0.3;
text-decoration: none;
transition-property: all;
transition-duration: .5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&:hover {
opacity: 0.7;
text-decoration: none;
}
}

View File

@@ -0,0 +1,122 @@
.mf-bar {
width: 100%;
position: fixed;
left: 0;
right: 0;
display: table;
padding-left: 5px;
padding-right: 5px;
z-index: 20000;
&.mf-bar-top {
top: 0;
}
&.mf-bar-bottom {
bottom: 0;
}
.mf-bar-collapse {
width: 100px;
display: table-cell;
vertical-align: middle;
line-height: 13px;
}
.mf-content {
display: table-cell;
vertical-align: middle;
text-align: center;
.mf-link {
margin-left: 10px;
padding: 2px 15px;
}
.mf-headline {
display: inline-block;
}
}
&.mf-bar-regular {
height: 30px;
font-size: 14px;
&.mf-bar-top .mf-bar-collapser-icon svg {
margin: 3px 0 0 0;
}
&.mf-bar-bottom .mf-bar-collapser-icon svg {
margin: -3px 0 0 0;
}
.mauticform-input, select, .mauticform-button, .mauticform-pagebreak {
padding: 3px 6px;
font-size: 0.9em;
}
}
&.mf-bar-large {
height: 50px;
font-size: 17px;
&.mf-bar-top .mf-bar-collapser-icon svg {
margin: 5px 0 0 0;
}
&.mf-bar-bottom .mf-bar-collapser-icon svg {
margin: -5px 0 0 0;
}
.mf-link {
font-size: 1em;
}
.mauticform-input, select, .mauticform-button, .mauticform-pagebreak {
font-size: 1em;
}
}
.mauticform-row, .mauticform-checkboxgrp-row, .mauticform-radiogrp-row {
display: inline-block;
margin-right: 3px;
}
.mauticform-row .mauticform-input, .mauticform-row select {
color: #000000;
}
.mauticform-label {
display: none;
}
.mauticform_wrapper {
display: inline-block;
}
.mf-responsive {
.mf-bar-collapse, .mf-bar-collapser {
display: none !important;
}
}
}
{{ include('@MauticFocus/Builder/Bar/collapser.less.twig', with_context=false) }}
@media only screen and (max-width: 667px) {
& .mf-bar-collapse, & .mf-bar-collapser {
display: none !important;
}
}
{% if preview is not empty %}
{{ include('@MauticFocus/Builder/Bar/animations.less.twig', with_context=false) }}
{{ include('@MauticFocus/Builder/Bar/shared.less.twig', with_context=false) }}
.mf-bar {
&.mf-animate {
.barAnimate();
}
}
.mf-bar, .mf-bar-collapser, .mf-bar-collapser-sticky {
position: absolute !important;
}
{% endif %}

View File

@@ -0,0 +1,76 @@
.modalTranslate(@x; @y) {
-webkit-transform: translate(@x, @y);
-ms-transform: translate(@x, @y);
transform: translate(@x, @y);
}
.modalAnimate() {
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-duration: 0.3s;
animation-duration: 0.3s;
-webkit-animation-timing-function: ease-in-out;
animation-timing-function: ease-in-out;
}
.modalAnimateName(@name) {
-webkit-animation-name: @name;
animation-name: @name;
}
.modalSlideDownTop() {
0% {
margin-top: -100%;
.modalTranslate(-50%, -150%);
}
100% {
margin-top: 0;
.modalTranslate(-50%, 0);
}
}
@-webkit-keyframes mf-modal-slide-down-top {
.modalSlideDownTop;
}
@keyframes mf-modal-slide-down-top {
.modalSlideDownTop;
}
.modalSlideDownMiddle() {
0% {
margin-top: -100%;
.modalTranslate(-50%, -150%);
}
100% {
margin-top: 0;
.modalTranslate(-50%, -50%);
}
}
@-webkit-keyframes mf-modal-slide-down-middle {
.modalSlideDownMiddle;
}
@keyframes mf-modal-slide-down-middle {
.modalSlideDownMiddle;
}
.modalSlideUpBottom() {
0% {
margin-bottom: -100%;
.modalTranslate(-50%, 150%);
}
100% {
margin-bottom: 0;
.modalTranslate(-50%, 0);
}
}
@-webkit-keyframes mf-modal-slide-up-bottom {
.modalSlideUpBottom;
}
@keyframes mf-modal-slide-up-bottom {
.modalSlideUpBottom;
}

View File

@@ -0,0 +1,40 @@
{%- set props = focus.properties -%}
{%- set style = focus.style -%}
{%- set placement = props[style].placement is defined ? props[style].placement|replace({'_': '-'}) : false -%}
{%- set animate = not preview and props.animate is defined and props.animate == 1 -%}
<style scoped>
.mf-{{ style }} {
border-color: #{{ props.colors.primary }};
}
</style>
<div class="mautic-focus mf-{{ style }} {% if placement %}mf-{{ style }}-{{ placement }}{% endif %} {% if animate %}mf-animate{% endif %}">
<div class="mf-{{ style }}-container">
<div class="mf-{{ style }}-close">
<a href="javascript:void(0)" {% if not preview %}onclick="Mautic.closeFocusModal('{{ style }}')"{% endif %}>x</a>
</div>
<div class="mf-content">
{% if htmlMode in ['editor', 'html'] %}
{{ focus[htmlMode]|raw }}
{% else %}
<div class="mf-headline">{{ props['content']['headline'] }}</div>
{% if props['content']['tagline'] is defined %}
<div class="mf-tagline">{{ props['content']['tagline'] }}</div>
{% endif %}
<div class="mf-inner-container">
{% if 'form' == focus.type %}
{focus_form}
{% elseif 'link' == focus.type %}
<a href="{% if not preview %}{{ clickUrl }}{% else %}#{% endif %}"
class="mf-link"
target="{% if props.content.link_new_window %}_new{% else %}_parent{% endif %}">
{{ props['content']['link_text'] }}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% if 'modal' == style %}
<div class="mf-move-to-parent mf-{{ style }}-overlay mf-{{ style }}-overlay-{{ focus['id'] }}"></div>
{% endif %}

View File

@@ -0,0 +1,11 @@
.mf-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #000000;
z-index: 21002;
width: 100%;
height: 100%;
opacity: .7;
}

View File

@@ -0,0 +1,46 @@
{{ include('@MauticFocus/Builder/Modal/animations.less.twig', with_context=false) }}
{{ include('@MauticFocus/Builder/Modal/overlay.less.twig', with_context=false) }}
.mf-modal-iframe {
position: fixed;
z-index: 21003;
left: 50%;
&.mf-animate {
.modalAnimate();
}
&.mf-modal-iframe-top {
top: 10px;
margin-top: -100%;
.modalTranslate(-50%, 0);
&.mf-animate {
.modalAnimateName(mf-modal-slide-down-top);
}
}
&.mf-modal-iframe-middle {
top: 50%;
margin-top: -100%;
.modalTranslate(-50%, -50%);
&.mf-animate {
.modalAnimateName(mf-modal-slide-down-middle);
}
}
&.mf-modal-iframe-bottom {
bottom: 10px;
margin-bottom: -100%;
.modalTranslate(-50%, 0);
&.mf-animate {
.modalAnimateName(mf-modal-slide-up-bottom);
}
}
&.mf-loaded {
margin-top: 0;
margin-bottom: 0;
}
}

View File

@@ -0,0 +1,114 @@
.mf-modal {
position: relative;
opacity: 1;
z-index: 2000;
margin: auto;
padding: 45px;
border-radius: 4px;
border-width: 6px 1px 1px 1px;
border-style: solid;
background: #fff;
max-width: 40em;
text-align: center;
.mf-content {
margin-bottom: 30px;
.mf-headline {
font-size: 1.6em;
font-weight: 600;
}
.mf-tagline {
font-size: 1.2em;
font-weight: normal;
margin-top: 4px;
}
a.mf-link {
display: block;
max-width: 70%;
padding: 10px;
margin: auto;
font-size: 1.2em;
}
}
.mf-modal-close {
position: fixed;
top: 5px;
right: 8px;
a {
font-size: 1.4em;
color: #757575;
opacity: .4;
text-decoration: none;
}
a:hover {
opacity: .8;
text-decoration: none;
}
}
.mauticform-input, .mauticform-row select, .mauticform-button, .mauticform-pagebreak {
width: 75%;
height: 35px;
margin-bottom: 5px;
}
}
.mf-responsive.mf-modal, .mf-responsive .mf-modal {
width: 90%;
padding: 10px;
}
{% if preview is not empty %}
{{ include('@MauticFocus/Builder/Modal/animations.less.twig', with_context=false) }}
{{ include('@MauticFocus/Builder/Modal/overlay.less.twig', with_context=false) }}
.mf-modal, .mf-modal-overlay {
position: absolute !important;
}
.mf-modal {
z-index: 1023;
left: 50%;
&.mf-animate {
.modalAnimate();
}
&.mf-modal-top {
top: 10px;
.modalTranslate(-50%, 0);
&.mf-animate {
.modalAnimateName(mf-modal-slide-down-top);
}
}
&.mf-modal-middle {
top: 50%;
.modalTranslate(-50%, -50%);
&.mf-animate {
.modalAnimateName(mf-modal-slide-down-middle);
}
}
&.mf-modal-bottom {
bottom: 10px;
.modalTranslate(-50%, 0);
&.mf-animate {
.modalAnimateName(mf-modal-slide-up-bottom);
}
}
}
.mf-modal-overlay {
z-index: 1022;
}
{% endif %}

View File

@@ -0,0 +1,75 @@
.notificationTranslate(@percent) {
-webkit-transform: translateX(@percent);
-ms-transform: translateX(@percent);
transform: translateX(@percent);
}
.notificationAnimate() {
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-timing-function: ease-in-out;
animation-timing-function: ease-in-out;
}
.notificationName(@name) {
-webkit-animation-name: @name;
animation-name: @name;
}
.notificationSlideLeft() {
0% {
.notificationTranslate(150%);
}
50% {
.notificationTranslate(-8%);
}
65% {
.notificationTranslate(4%);
}
80% {
.notificationTranslate(-4%);
}
95% {
.notificationTranslate(2%);
}
100% {
.notificationTranslate(0%);
}
}
@-webkit-keyframes mf-notification-slide-left {
.notificationSlideLeft;
}
@keyframes mf-notification-slide-left {
.notificationSlideLeft;
}
.notificationSlideRight() {
0% {
.notificationTranslate(-150%);
}
50% {
.notificationTranslate(8%);
}
65% {
.notificationTranslate(-4%);
}
80% {
.notificationTranslate(4%);
}
95% {
.notificationTranslate(-2%);
}
100% {
.notificationTranslate(0%);
}
}
@-webkit-keyframes mf-notification-slide-right {
.notificationSlideRight;
}
@keyframes mf-notification-slide-right {
.notificationSlideRight;
}

View File

@@ -0,0 +1,6 @@
{{- include('@MauticFocus/Builder/Modal/index.html.twig', {
'focus': focus,
'preview': preview,
'clickUrl': clickUrl,
'htmlMode': htmlMode,
}) -}}

View File

@@ -0,0 +1,58 @@
{{ include('@MauticFocus/Builder/Notification/animations.less.twig', with_context=false) }}
.mf-notification-iframe {
position: fixed;
z-index: 21001;
margin-top: -100%;
&.mf-loaded {
margin-top: 0;
margin-bottom: 0;
&.mf-animate {
.notificationAnimate();
}
&.mf-notification-iframe-top-left {
top: 5px;
left: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-right);
}
}
&.mf-notification-iframe-top-right {
top: 5px;
right: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-left);
}
}
&.mf-notification-iframe-bottom-left {
bottom: 5px;
left: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-right);
}
}
&.mf-notification-iframe-bottom-right {
bottom: 5px;
right: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-left);
}
}
&.mf-responsive {
left: 0 !important;
right: 0 !important;
}
}
}

View File

@@ -0,0 +1,107 @@
.mf-notification {
position: relative;
opacity: 1;
z-index: 2000;
margin: auto;
background: #fff;
border-radius: 4px;
border-width: 6px 1px 1px 1px;
border-style: solid;
min-height: 8em;
padding: 10px 20px;
width: 350px;
.mf-content {
margin-bottom: 30px;
.mf-headline {
font-size: 1.2em;
font-weight: 600;
}
.mf-tagline {
font-size: 1em;
font-weight: normal;
margin-top: 4px;
}
}
.mf-notification-close {
position: fixed;
top: 5px;
right: 8px;
a {
font-size: 1em;
color: #757575;
opacity: .4;
text-decoration: none;
&:hover {
opacity: .8;
text-decoration: none;
}
}
}
.mauticform-input, .mauticform-row select, .mauticform-button, .mauticform-pagebreak {
width: 100%;
height: 28px;
margin-bottom: 2px;
}
}
.mf-responsive.mf-notification, .mf-responsive .mf-notification {
width: 90%;
padding: 10px;
left: 0;
right: 0;
}
{% if preview is not empty %}
.mf-notification {
position: absolute !important;
&.mf-animate {
.notificationAnimate();
}
&.mf-notification-top-left {
top: 5px;
left: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-right);
}
}
&.mf-notification-top-right {
top: 5px;
right: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-left);
}
}
&.mf-notification-bottom-left {
bottom: 5px;
left: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-right);
}
}
&.mf-notification-bottom-right {
bottom: 5px;
right: 5px;
&.mf-animate {
.notificationName(mf-notification-slide-left);
}
}
}
{{ include('@MauticFocus/Builder/Notification/animations.less.twig', with_context=false) }}
{% endif %}

View File

@@ -0,0 +1,6 @@
{{- include('@MauticFocus/Builder/Modal/index.html.twig', {
'focus': focus,
'preview': preview,
'clickUrl': clickUrl,
'htmlMode': htmlMode,
}, with_context=false) -}}

View File

@@ -0,0 +1,19 @@
.mf-page-iframe {
position: fixed;
z-index: 21005;
top: 1px;
right: 1px;
left: 1px;
bottom: 1px;
width: 100%;
height: 100%;
}
@media only screen and (max-width: 667px) {
.mf-page-iframe {
top: 0;
right: 0;
left: 0;
bottom: 0;
}
}

View File

@@ -0,0 +1,78 @@
.mf-page {
position: fixed;
opacity: 1;
z-index: 20000;
margin: auto;
padding: 45px;
background: #fff;
border-radius: 2px;
border-width: 6px 1px 1px 1px;
border-style: solid;
top: 1px;
right: 1px;
left: 1px;
bottom: 1px;
text-align: center;
.mf-content {
position: absolute;
min-width: 75%;
top: 50%;
left: 50%;
right: 0;
transform: translate(-50%, -50%);
-webkit-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
margin-bottom: 30px;
.mf-headline {
font-size: 2.5em;
font-weight: 600;
}
.mf-tagline {
font-size: 1.8em;
font-weight: normal;
margin-top: 4px;
}
a.mf-link {
padding: 10px 15px;
display: block;
max-width: 50%;
margin: auto;
font-size: 1.8em;
}
}
.mf-page-close {
position: absolute;
top: 0;
right: 8px;
a {
font-size: 1.8em;
color: #757575;
opacity: .4;
text-decoration: none;
&:hover {
opacity: .8;
text-decoration: none;
}
}
}
.mauticform-input, .mauticform-row select, .mauticform-button, .mauticform-pagebreak {
width: 75%;
height: 40px;
font-size: 1.6em;
margin-bottom: 8px;
}
}
{% if preview is not empty %}
.mf-page {
position: absolute !important;
}
{% endif %}

View File

@@ -0,0 +1,59 @@
{#
Variables
- focus (MauticPlugin\MauticFocusBundle\Entity\Focus)
- preview (optional)
- clickUrl
- htmlMode
Notes
- focus.htmlMode === htmlMode? is this always true?
#}
{% set templateBase = '@MauticFocus/Builder/' ~ focus.style|capitalize ~ '/index.html.twig' %}
{% set preview = preview|default(false) %}
{% set clickUrl = clickUrl|default('#') %}
{% set props = focus.properties %}
<div>
<style scoped>
.mautic-focus * {
all: revert;
box-sizing: border-box;
}
.mautic-focus {
font-family: {{ props.content.font }};
color: #{{ props.colors.text|replace({'#': ''}) }};
}
{% if props.colors is defined and props.colors is not empty %}
.mf-content a.mf-link, .mf-content .mauticform-button, .mf-content .mauticform-pagebreak {
background-color: #{{ props.colors.button|replace({'#': ''}) }};
color: #{{ props.colors.button_text }};
}
.mauticform-input:focus, select:focus {
border: 1px solid #{{ props.colors.button|replace({'#': ''}) }};
}
{% endif %}
{% if preview %}
{{ include('@MauticFocus/Builder/style.less.twig', {
'preview': true,
'focus': focus,
}, with_context=false) }}
{% endif %}
</style>
{{ include(templateBase, {
'focus': focus,
'preview': preview,
'clickUrl': clickUrl,
'htmlMode': htmlMode,
}, with_context=false) }}
{% if focus.properties.content.css is defined and focus.properties.content.css is not empty %}
<style scoped>
{{ focus.properties.content.css|raw }}
</style>
{% endif %}
{# Add view tracking image #}
{% if not preview %}
<img src="{{ url('mautic_focus_pixel', {'id': focus.id}, true) }}" alt="Mautic Focus" style="display: none;"/>
{% endif %}
</div>

View File

@@ -0,0 +1,118 @@
{#
Variables
- form
- pages
- lastPage
- style
- focusId
- preview
- contactFields
- companyFields
- viewOnlyFields
- displayManager
#}
{% set formName = '_' ~ inputAlphanum(inputTransliterate(form.name))|lower ~ '_focus' %}
{% set jsFormName = formName|trim('_', 'left') %}
{% set fields = form.fields %}
{% set required = [] %}
<!-- START FOCUS FORM -->
{{ include('@MauticForm/Builder/_script.html.twig', {'form': form, 'formName': formName}, with_context=false) }}
<script>
var MauticFocusHandler = function (messageType, message) {
var wrapper = document.getElementById('mauticform_wrapper{{ formName }}');
var innerForm = wrapper.getElementsByClassName('mauticform-innerform');
innerForm[0].style.display = "none";
{% if 'page' == style %}
document.getElementById('mauticform{{ formName }}_' + messageType).style.fontSize = "2em";
{% elseif 'bar' == style %}
document.getElementById('mauticform{{ formName }}_' + messageType).style.fontSize = "1.1em";
{% endif %}
var headline = document.getElementsByClassName('mf-headline');
if (headline.length) {
headline[0].style.display = "none";
}
var tagline = document.getElementsByClassName('mf-tagline');
if (tagline.length) {
tagline[0].style.display = "none";
}
if (message) {
document.getElementById('mauticform{{ formName }}_' + messageType).innerHTML = message;
}
if (messageType == 'error') {
setTimeout(function () {
if (headline.length) {
{% if 'bar' == style %}
headline[0].style.display = "inline-block";
{% else %}
headline[0].style.display = "block";
{% endif %}
}
if (tagline.length) {
tagline[0].style.display = "inherit";
}
innerForm[0].style.display = "inherit";
document.getElementById('mauticform{{ formName }}_' + messageType).innerHTML = '';
}, 1500);
}
};
if (typeof MauticFormCallback == 'undefined') {
var MauticFormCallback = {};
}
MauticFormCallback["{{ jsFormName }}"] = {
onMessageSet: function (data) {
if (data.message) {
MauticFocusHandler(data.type);
}
},
onErrorMark: function (data) {
if (data.validationMessage) {
MauticFocusHandler('error', data.validationMessage);
return true;
}
},
onResponse: function (data) {
if (data.download) {
document.getElementById('mauticiframe{{ formName }}').src = data.download;
if (data.redirect) {
setTimeout(function () {
window.top.location = data.redirect;
}, 2000);
}
return true;
} else if (data.redirect) {
window.top.location = data.redirect;
return true;
}
return false;
}
}
</script>
{% set formExtra %}
<input type="hidden" name="mauticform[focusId]" id="mauticform{{ formName }}_focus_id" value="{{ focusId }}"/>
{% endset %}
{{ include('@MauticForm/Builder/form.html.twig', {
'form': form,
'formPages': pages,
'lastFormPage': lastPage,
'formExtra': formExtra,
'action': preview ? '#' : null,
'suffix': '_focus',
'contactFields': contactFields,
'companyFields': companyFields,
'viewOnlyFields': viewOnlyFields,
'displayManager': displayManager,
}, with_context=false) }}
<!-- END FOCUS FORM -->

View File

@@ -0,0 +1,750 @@
{#
Variables
- focus (MauticPlugin\MauticFocusBundle\Entity\Focus)
- preview (bool, default: false)
- clickUrl (string)
#}
{%- set style = focus.style -%}
{%- set props = focus.properties -%}
{%- set useScrollEvent = (props.when in ['scroll_slight', 'scroll_middle', 'scroll_bottom']) -%}
{%- set useUnloadEvent = ('leave' == props.when) -%}
{%- set useTimeout = props.timeout|default(0) -%}
{%- set animate = props.animate is not defined or (props.animate is defined and props.animate is not empty) -%}
{%- set linkActivation = props.link_activation is not defined or (props.link_activation is defined and props.link_activation is not empty) -%}
{%- set clickUrl = clickUrl|default(props.content.link_url) -%}
{%- if '5seconds' == props.when -%}
{%- set useTimeout = 5 -%}
{%- elseif 'minute' == props.when -%}
{%- set useTimeout = 60 -%}
{%- endif -%}
{%- if useTimeout > 0 -%}
{%- set timeout = useTimeout * 1000 -%}
{%- endif -%}
{%- set cssContent = include('@MauticFocus/Builder/style.less.twig', {
'preview': preview,
'focus': focus,
}, with_context=false) -%}
{%- set parentCssContent = include('@MauticFocus/Builder/parent.less.twig', {
'preview': preview,
}, with_context=false) -%}
{%- if 'bar' is same as style -%}
{%- set iframeClass = 'mf-bar-iframe mf-bar-iframe-' ~ props.bar.placement ~ ' mf-bar-iframe-' ~ props.bar.size -%}
{%- if props.bar.sticky -%}
{% set iframeClass = iframeClass ~ ' mf-bar-iframe-sticky' -%}
{%- endif -%}
{%- elseif 'modal' is same as style or 'notification' is same as style -%}
{%- set iframeClass = 'mf-' ~ style ~ '-iframe mf-' ~ style ~ '-iframe-' ~ props[style].placement|replace({'_': '-'}) -%}
{%- else -%}
{%- set iframeClass = 'mf-' ~ style ~ '-iframe' -%}
{%- endif -%}
(function (window) {
if (typeof window.MauticFocusParentHeadStyleInserted == 'undefined') {
window.MauticFocusParentHeadStyleInserted = false;
}
window.MauticFocus{{ focus.id }} = function () {
var Focus = {
debug: {{ ('dev' == app.environment) ? 'true' : 'false' }},
modalsDismissed: {},
ignoreConverted: {% if 'notification' is not same as focus.type and props.stop_after_conversion is defined and props.stop_after_conversion is not empty %}true{% else %}false{% endif %},
ignoreClosed: {% if props.stop_after_close is defined and props.stop_after_close is not empty %}true{% else %}false{% endif %},
// Initialize the focus
initialize: function () {
if (Focus.debug)
console.log('initialize()');
Focus.insertStyleIntoHead();
Focus.registerFocusEvent();
// Add class to body
Focus.addClass(document.getElementsByTagName('body')[0], 'MauticFocus{{ style|capitalize }}');
},
// Register click events for toggling bar, closing windows, etc
registerClickEvents: function () {
{% if 'bar' == style %}
var isTop = Focus.hasClass(Focus.iframeFocus, 'mf-bar-top');
Focus.setDefaultBarPosition(isTop);
var collapser = document.getElementsByClassName('mf-bar-collapser-{{ focus.id }}');
if (collapser[0]) {
collapser[0].addEventListener('click', function () {
Focus.toggleBarCollapse(collapser[0], false);
});
}
{% else %}
var closer = Focus.iframeDoc.getElementsByClassName('mf-{{ style }}-close');
var aTag = closer[0].getElementsByTagName('a');
var container = Focus.iframeDoc.getElementsByClassName('mf-{{ style }}');
container.onclick = function(e) {
if (e) { e.stopPropagation(); }
else { window.event.cancelBubble = true; }
};
aTag[0].addEventListener('click', function (event) {
if (typeof Focus.modalsDismissed["{{ focus.id }}"] == 'undefined') {
Focus.incrementCloseCount();
}
document.dispatchEvent(new CustomEvent("focus_{{ focus.id }}_close"));
});
document.addEventListener("focus_{{ focus.id }}_close", function (event) {
// Prevent multiple engagements for link clicks on exit intent
Focus.modalsDismissed["{{ focus.id }}"] = true;
// Remove iframe
if (Focus.iframe.parentNode) {
Focus.iframe.parentNode.removeChild(Focus.iframe);
}
var overlays = document.getElementsByClassName('mf-modal-overlay-{{ focus.id }}');
if (overlays.length) {
overlays[0].parentNode.removeChild(overlays[0]);
}
});
{% endif %}
{% if 'link' == focus.type %}
var links = Focus.iframeDoc.getElementsByClassName('mf-link');
if (links.length) {
links[0].addEventListener('click', function (event) {
Focus.convertVisitor();
});
}
{% elseif 'form' == focus.type %}
var buttons = Focus.iframeDoc.getElementsByClassName('mauticform-button');
if (buttons.length) {
buttons[0].addEventListener('click', function (event) {
Focus.convertVisitor();
});
}
{% endif %}
},
setDefaultBarPosition: function (isTop) {
if (isTop) {
Focus.iframe.style.marginTop = 0;
}else {
Focus.iframe.style.marginBottom = 0;
}
},
toggleBarCollapse: function (collapser, useCookie) {
var svg = collapser.getElementsByTagName('svg');
var g = svg[0].getElementsByTagName('g');
var currentSize = svg[0].getAttribute('data-transform-size');
var currentDirection = svg[0].getAttribute('data-transform-direction');
var currentScale = svg[0].getAttribute('data-transform-scale');
if (useCookie) {
if (Focus.cookies.hasItem('mf-bar-collapser-{{ focus.id }}')) {
var newDirection = Focus.cookies.getItem('mf-bar-collapser-{{ focus.id }}');
if (isNaN(newDirection)) {
var newDirection = currentDirection;
}
} else {
// Set cookie with current direction
var newDirection = currentDirection;
}
} else {
var newDirection = (parseInt(currentDirection) * -1);
Focus.cookies.setItem('mf-bar-collapser-{{ focus.id }}', newDirection);
}
setTimeout(function () {
g[0].setAttribute('transform', 'scale(' + currentScale + ') rotate(' + newDirection + ' ' + currentSize + ' ' + currentSize + ')');
svg[0].setAttribute('data-transform-direction', newDirection);
}, 500);
var isTop = Focus.hasClass(Focus.iframeFocus, 'mf-bar-top');
if ((!isTop && newDirection == 90) || (isTop && newDirection == -90)) {
// Open it up
Focus.setDefaultBarPosition(isTop);
Focus.removeClass(collapser, 'mf-bar-collapsed');
Focus.enableIframeResizer();
} else {
// Collapse it
var iframeHeight = Focus.iframe.style.height;
iframeHeight.replace('px', '');
var newMargin = (parseInt(iframeHeight) * -1) + 'px';
if (isTop) {
Focus.iframe.style.marginTop = newMargin;
} else {
Focus.iframe.style.marginBottom = newMargin;
}
Focus.addClass(collapser, 'mf-bar-collapsed');
Focus.disableIFrameResizer();
}
},
// Register scroll events, etc
registerFocusEvent: function () {
window.addEventListener('resize', function () {
Focus.disableIFrameResizer();
Focus.enableIframeResizer();
});
if (Focus.debug)
console.log('registerFocusEvent()');
{% if useScrollEvent %}
if (Focus.debug)
console.log('scroll event registered');
{% if useTimeout %}
if (Focus.debug)
console.log('timeout event registered');
setTimeout(function () {
window.addEventListener('scroll', Focus.engageVisitorAtScrollPosition);
}, {{ timeout }});
{% else %}
window.addEventListener('scroll', Focus.engageVisitorAtScrollPosition);
{% endif %}
{% elseif useUnloadEvent %}
if (Focus.debug)
console.log('show when visitor leaves');
{% if useTimeout %}
if (Focus.debug)
console.log('timeout event registered');
setTimeout(function () {
document.documentElement.addEventListener('mouseleave', Focus.engageVisitor);
}, {{ timeout }});
{% else %}
document.documentElement.addEventListener('mouseleave', Focus.engageVisitor);
{% endif %}
// Add a listener to every link
{% if linkActivation %}
var elements = document.getElementsByTagName('a');
for (var i = 0, len = elements.length; i < len; i++) {
var href = elements[i].getAttribute('href');
if (href && href.indexOf('#') != 0 && href.indexOf('javascript:') != 0) {
elements[i].onclick = function (event) {
if (typeof Focus.modalsDismissed["{{ focus.id }}"] == 'undefined') {
if (Focus.engageVisitor()) {
event.preventDefault();
}
}
}
}
}
{% endif %}
{% else %}
if (Focus.debug)
console.log('show immediately');
{% if useTimeout %}
if (Focus.debug)
console.log('timeout event registered');
setTimeout(function () {
// Give a slight delay to allow browser to process style injection into header
Focus.engageVisitor();
}, {{ timeout }});
{% else %}
// Give a slight delay to allow browser to process style injection into header
Focus.engageVisitor();
{% endif %}
{% endif %}
},
// Insert global style into page head
insertStyleIntoHead: function () {
if (!window.MauticFocusParentHeadStyleInserted) {
if (Focus.debug)
console.log('insertStyleIntoHead()');
var css = "{{- parentCssContent|e('js') -}}",
head = document.head || document.getElementsByTagName('head')[0],
style = document.createElement('style');
head.appendChild(style);
style.type = 'text/css';
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
} else if (Focus.debug) {
console.log('Shared style already inserted into head');
}
},
// Inserts styling into the iframe's head
insertFocusStyleIntoIframeHead: function () {
// Insert style into iframe header
var frameDoc = Focus.iframe.contentDocument;
var frameHead = frameDoc.getElementsByTagName('head').item(0);
var css = "{{- cssContent|e('js') -}}";
var style = frameDoc.createElement('style');
style.type = 'text/css';
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(frameDoc.createTextNode(css));
}
frameHead.appendChild(style);
var metaTag = frameDoc.createElement('meta');
metaTag.name = "viewport"
metaTag.content = "width=device-width,initial-scale=1,minimum-scale=1.0 maximum-scale=1.0"
frameHead.appendChild(metaTag);
},
// Generates the focus HTML
engageVisitor: function () {
var now = Math.floor(Date.now() / 1000);
if (Focus.cookies.hasItem('mautic_focus_{{ focus.id }}')) {
if (Focus.debug)
console.log('Cookie exists thus checking frequency');
var lastEngaged = parseInt(Focus.cookies.getItem('mautic_focus_{{ focus.id }}')),
frequency = '{{ props.frequency }}',
engage;
if (Focus.ignoreConverted && lastEngaged == -1) {
if (Focus.debug)
console.log('Visitor converted; abort');
return false;
}
switch (frequency) {
case 'once':
engage = false;
if (Focus.debug)
console.log('Engage once, abort');
break;
case 'everypage':
engage = true;
if (Focus.debug)
console.log('Engage on every page, continue');
break;
case 'q2min':
engage = (now - lastEngaged) >= 120;
if (Focus.debug) {
var debugMsg = 'Engage q2 minute, ';
if (engage) {
debugMsg += 'continue';
} else {
debugMsg += 'engage in ' + (120 - (now - lastEngaged)) + ' seconds';
}
console.log(debugMsg);
}
break;
case 'q15min':
engage = (now - lastEngaged) >= 900;
if (Focus.debug) {
var debugMsg = 'Engage q15 minute, ';
if (engage) {
debugMsg += 'continue';
} else {
debugMsg += 'engage in ' + (120 - (now - lastEngaged)) + ' seconds';
}
console.log(debugMsg);
}
break;
case 'hourly':
engage = (now - lastEngaged) >= 3600;
if (Focus.debug) {
var debugMsg = 'Engage hourly, ';
if (engage) {
debugMsg += 'continue';
} else {
debugMsg += 'engage in ' + (120 - (now - lastEngaged)) + ' seconds';
}
console.log(debugMsg);
}
break;
case 'daily':
engage = (now - lastEngaged) >= 86400;
if (Focus.debug) {
var debugMsg = 'Engage daily, ';
if (engage) {
debugMsg += 'continue';
} else {
debugMsg += 'engage in ' + (120 - (now - lastEngaged)) + ' seconds';
}
console.log(debugMsg);
}
break;
}
if (!engage) {
return false;
}
}
if (Focus.ignoreClosed && Focus.getCloseCount() > 0) {
if (Focus.debug)
console.log('Visitor has closed the focus; abort');
return false;
}
if (Focus.debug)
console.log('engageVisitor()');
// Inject iframe
Focus.createIframe();
// Inject content into iframe
Focus.iframeDoc.open();
Focus.iframeDoc.write("{focus_content}");
Focus.iframeDoc.close();
var animate = {% if animate %}true{% else %}false{% endif %};
Focus.iframe.onload = function() {
if (Focus.debug)
console.log('iframe loaded for '+Focus.iframe.getAttribute('src'));
// Resize iframe
if (Focus.enableIframeResizer()) {
// Give iframe chance to resize
setTimeout(function () {
if (animate) {
Focus.addClass(Focus.iframe, "mf-animate");
}
Focus.addClass(Focus.iframe, "mf-loaded");
}, 35);
} else {
if (animate) {
Focus.addClass(Focus.iframe, "mf-animate");
}
Focus.addClass(Focus.iframe, "mf-loaded");
}
}
// Set body margin to 0
Focus.iframeDoc.getElementsByTagName('body')[0].style.margin = 0;
Focus.iframeDoc.getElementsByTagName('body')[0].style.overflowX = 'hidden';
// Find elements that should be moved to parent
var move = Focus.iframeDoc.getElementsByClassName('mf-move-to-parent');
for (var i = 0; i < move.length; i++) {
var bodyFirstChild = document.body.firstChild;
Focus.addClass(move[i], 'mf-moved-{{ focus.id }}');
bodyFirstChild.parentNode.insertBefore(move[i], Focus.iframe);
}
// Find elements that should be copied to parent
var copy = Focus.iframeDoc.getElementsByClassName('mf-copy-to-parent');
for (var i = 0; i < copy.length; i++) {
var bodyFirstChild = document.body.firstChild;
var clone = copy[i].cloneNode(true);
Focus.addClass(clone, 'mf-moved-{{ focus.id }}');
bodyFirstChild.parentNode.insertBefore(clone, Focus.iframe);
}
// Get the main focus element
var focus = Focus.iframeDoc.getElementsByClassName('mautic-focus');
Focus.iframeFocus = focus[0];
// Insert style into iframe head
Focus.insertFocusStyleIntoIframeHead();
// Register events
Focus.registerClickEvents();
{% if 'leave' == props.when %}
// Ensure user can leave
document.documentElement.removeEventListener('mouseleave', Focus.engageVisitor);
{% endif %}
// Add cookie of last engagement
if (Focus.debug)
console.log('mautic_focus_{{ focus.id }} cookie set for ' + now);
Focus.cookies.removeItem('mautic_focus_{{ focus.id }}');
Focus.cookies.setItem('mautic_focus_{{ focus.id }}', now, Infinity, '/');
{% if 'bar' == style %}
var collapser = document.getElementsByClassName('mf-bar-collapser-{{ focus.id }}');
if (animate) {
// Give iframe chance to resize
setTimeout(function () {
Focus.toggleBarCollapse(collapser[0], true);
}, 35);
} else {
Focus.toggleBarCollapse(collapser[0], true);
}
{% endif %}
return true;
},
// Enable iframe resizer
enableIframeResizer: function () {
if (typeof Focus.iframeResizerEnabled !== 'undefined') {
return true;
}
if (typeof Focus.iframe === 'undefined') {
return false;
}
{% if style in ['modal', 'notification', 'bar'] %}
Focus.iframeHeight = 0;
Focus.iframeWidth = 0;
Focus.iframeResizeInterval = setInterval(function () {
if (Focus.iframeHeight !== Focus.iframe.style.height) {
var useHeight = ((window.innerHeight < Focus.iframeFocus.offsetHeight) ?
window.innerHeight : Focus.iframeFocus.offsetHeight);
useHeight += 10;
useHeight = useHeight + 'px';
if (Focus.debug) {
console.log('window inner height = ' + window.innerHeight);
console.log('iframe offset height = ' + Focus.iframeFocus.offsetHeight);
console.log('iframe height set to ' + useHeight)
}
Focus.iframe.style.height = useHeight;
Focus.iframeHeight = useHeight;
}
{% if style in ['modal', 'notification'] %}
if (Focus.iframeWidth !== Focus.iframe.style.width) {
if (Focus.debug) {
console.log('window inner width = ' + window.innerWidth);
console.log('iframe offset width = ' + Focus.iframeFocus.offsetWidth);
}
if (window.innerWidth < Focus.iframeFocus.offsetWidth) {
// Responsive iframe
Focus.addClass(Focus.iframeFocus, 'mf-responsive');
Focus.addClass(Focus.iframe, 'mf-responsive');
Focus.iframe.style.width = window.innerWidth + 'px';
Focus.iframe.width = window.innerWidth;
if (Focus.debug)
console.log('iframe set to responsive width: ');
} else {
Focus.iframe.style.width = Focus.iframeFocus.offsetWidth + 'px';
Focus.iframe.width = Focus.iframeFocus.offsetWidth + 'px';
Focus.removeClass(Focus.iframeFocus, 'mf-responsive');
Focus.removeClass(Focus.iframe, 'mf-responsive');
if (Focus.debug)
console.log('iframe not a responsive width');
}
Focus.iframeWidth = Focus.iframe.style.width;
}
{% endif %}
}, 35);
Focus.iframeResizerEnabled = true;
return true;
{% else %}
return false;
{% endif %}
},
// Disable iframe resizer
disableIFrameResizer: function () {
if (typeof Focus.iframeResizerEnabled !== 'undefined') {
delete(Focus.iframeResizerEnabled);
}
{% if style in ['modal', 'notification', 'bar'] %}
clearInterval(Focus.iframeResizeInterval);
{% endif %}
},
// Create iframe to load into body
createIframe: function () {
if (Focus.debug)
console.log('createIframe()');
Focus.iframe = document.createElement('iframe');
Focus.iframe.style.border = 0;
Focus.iframe.style.width = "100%";
Focus.iframe.style.height = "100%";
Focus.iframe.src = "about:blank";
Focus.iframe.scrolling = "auto";
Focus.iframe.className = "{{ iframeClass|raw }}";
var bodyFirstChild = document.body.firstChild;
bodyFirstChild.parentNode.insertBefore(Focus.iframe, bodyFirstChild);
Focus.iframeDoc = Focus.iframe.contentWindow.document;
},
// Execute event at current position
engageVisitorAtScrollPosition: function (event) {
var visualHeight = "innerHeight" in window
? window.innerHeight
: document.documentElement.offsetHeight;
var scrollPos = window.pageYOffset,
atPos = 0;
{% if 'scroll_slight' is same as props.when %}
atPos = 10;
{% elseif 'scroll_middle' is same as props.when %}
scrollPos += (visualHeight / 2);
atPos = (document.body.scrollHeight / 2);
{% elseif 'scroll_bottom' is same as props.when %}
scrollPos += visualHeight;
atPos = document.body.scrollHeight;
{% endif %}
if (Focus.debug)
console.log('scrolling: ' + scrollPos + ' >= ' + atPos);
if (scrollPos >= atPos) {
window.removeEventListener('scroll', Focus.engageVisitorAtScrollPosition);
Focus.engageVisitor();
}
},
// Create cookie noting visitor has been converted if applicable
convertVisitor: function () {
if (Focus.ignoreConverted) {
if (Focus.debug)
console.log('Visitor converted');
Focus.cookies.setItem('mautic_focus_{{ focus.id }}', -1, Infinity, '/');
} else if (Focus.debug) {
console.log('Visitor converted but ignoreConverted not enabled');
}
},
// Element has class
hasClass: function (element, hasClass) {
return ( (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(" " + hasClass + " ") > -1 );
},
// Add class to element
addClass: function (element, addClass) {
if (!Focus.hasClass(element, addClass)) {
element.className += " " + addClass;
}
},
// Remove class from element
removeClass: function (element, removeClass) {
element.className = element.className.replace(new RegExp('\\b' + removeClass + '\\b'), '');
},
getCloseCount() {
if (Focus.cookies.hasItem('mautic_focus_{{ focus.id }}_closed')) {
return parseInt(Focus.cookies.getItem('mautic_focus_{{ focus.id }}_closed'))
} else {
return 0;
}
},
incrementCloseCount() {
var closeCount = Focus.getCloseCount();
Focus.cookies.setItem('mautic_focus_{{ focus.id }}_closed', ++closeCount);
},
// Cookie handling
cookies: {
/**
* :: cookies.js ::
* https://developer.mozilla.org/en-US/docs/Web/API/document.cookie
* http://www.gnu.org/licenses/gpl-3.0-standalone.html
*/
getItem: function (sKey) {
if (!sKey) {
return null;
}
return decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
},
setItem: function (sKey, sValue, vEnd, sPath, sDomain) {
if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) {
return false;
}
this.removeItem(sKey);
var sExpires = "";
if (vEnd) {
switch (vEnd.constructor) {
case Number:
sExpires = vEnd === Infinity ? "; expires=Fri, 31 Dec 9999 23:59:59 GMT" : "; max-age=" + vEnd;
break;
case String:
sExpires = "; expires=" + vEnd;
break;
case Date:
sExpires = "; expires=" + vEnd.toUTCString();
break;
}
}
document.cookie = encodeURIComponent(sKey) + "=" + encodeURIComponent(sValue) + sExpires + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "") + "; secure";
return true;
},
removeItem: function (sKey, sPath, sDomain) {
if (!this.hasItem(sKey)) {
return false;
}
document.cookie = encodeURIComponent(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "");
return true;
},
hasItem: function (sKey) {
if (!sKey) {
return false;
}
return (new RegExp("(?:^|;\\s*)" + encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
},
keys: function () {
var aKeys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, "").split(/\s*(?:\=[^;]*)?;\s*/);
for (var nLen = aKeys.length, nIdx = 0; nIdx < nLen; nIdx++) {
aKeys[nIdx] = decodeURIComponent(aKeys[nIdx]);
}
return aKeys;
}
}
};
return Focus;
}
// Initialize
MauticFocus{{ focus.id }}().initialize();
})(window);

View File

@@ -0,0 +1,14 @@
{%- set less -%}
{{- include('@MauticFocus/Builder/Bar/parent.less.twig', with_context=false) -}}
{{- include('@MauticFocus/Builder/Modal/parent.less.twig', with_context=false) -}}
{{- include('@MauticFocus/Builder/Notification/parent.less.twig', with_context=false) -}}
{{- include('@MauticFocus/Builder/Page/parent.less.twig', with_context=false) -}}
{%- endset -%}
{%- set css = less|less_compile -%}
{%- if preview is empty and 'dev' is same as app.environment -%}
{%- set css = css|css_minify -%}
{%- endif -%}
{{- css|raw -}}

View File

@@ -0,0 +1,86 @@
{#
#}
{% set preview = preview|default(false) %}
{% set less %}
.mf-bar-iframe {
z-index: 19000;
}
.mf-content {
line-height: 1.1;
.mf-inner-container {
margin-top: 20px;
}
a.mf-link, .mauticform-button, .mauticform-pagebreak {
padding: 5px 15px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
cursor: pointer;
text-align: center;
text-decoration: none;
border: none;
}
a.mf-link:hover, .mauticform-button:hover, .mauticform-pagebreak:hover {
opacity: 0.9;
text-decoration: none;
border: none;
}
.mauticform-pagebreak {
width: auto !important;
}
}
.mautic-focus {
{% if preview %}
.mauticform-row {
min-height: 0px;
}
{% endif %}
.mauticform_wrapper form {
padding: 0;
margin: 0;
}
.mauticform-input, select {
border-radius: 2px;
padding: 5px 8px;
color: #757575;
border: 1px solid #ababab;
}
.mauticform-input:focus, select:focus {
outline: none;
border: 1px solid #757575;
}
}
{{ include('@MauticFocus/Builder/Bar/style.less.twig', {
'preview': preview,
}, with_context=false) }}
{{ include('@MauticFocus/Builder/Modal/style.less.twig', {
'preview': preview,
}, with_context=false) }}
{{ include('@MauticFocus/Builder/Notification/style.less.twig', {
'preview': preview,
}, with_context=false) }}
{{ include('@MauticFocus/Builder/Page/style.less.twig', {
'preview': preview,
}, with_context=false) }}
{% endset %}
{% set css = less|less_compile %}
{% if preview is empty and 'dev' is same as app.environment %}
{% set css = css|css_minify %}
{% endif %}
{{ css|raw }}

View File

@@ -0,0 +1,168 @@
{#
#}
{{ includeScript('plugins/MauticFocusBundle/Assets/js/focus.js') }}
{% if items|length > 0 %}
<div class="table-responsive page-list">
<table class="table table-hover focus-list" id="focusTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#focusTable',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'focus',
'orderBy': 'f.name',
'text': 'mautic.core.name',
'class': 'col-focus-name',
'default': true,
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'focus',
'orderBy': 'c.title',
'text': 'mautic.core.category',
'class': 'visible-md visible-lg col-focus-category',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'focus',
'orderBy': 'f.type',
'text': 'mautic.focus.thead.type',
'class': 'visible-md visible-lg col-focus-type',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'focus',
'orderBy': 'f.style',
'text': 'mautic.focus.thead.style',
'class': 'visible-md visible-lg col-focus-style',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'focus',
'orderBy': 'f.id',
'text': 'mautic.core.id',
'class': 'visible-md visible-lg col-focus-id',
}) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': securityHasEntityAccess(permissions['focus:items:editown'], permissions['focus:items:editother'], item.createdBy),
'clone': permissions['focus:items:create'],
'delete': securityHasEntityAccess(permissions['focus:items:deleteown'], permissions['focus:items:deleteother'], item.createdBy),
},
'routeBase': 'focus',
}) }}
</td>
<td>
<div>
{{ include('@MauticCore/Helper/publishstatus_icon.html.twig', {'item': item, 'model': 'focus'}) }}
<a data-toggle="ajax" href="{{ path('mautic_focus_action', {'objectId': item.id, 'objectAction': 'view'}) }}">
{{ item.name }}
</a>
{{ include('@MauticProject/Modules/projects.html.twig') }}
</div>
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': item.description
}) }}
</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.category}) }}
</td>
<td class="visible-md visible-lg">{{ ('mautic.focus.type.' ~ item.type)|trans }}</td>
<td class="visible-md visible-lg">{{ ('mautic.focus.style.' ~ item.style)|trans }}</td>
<td class="visible-md visible-lg">{{ item.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'baseUrl': path('mautic_focus_index'),
'sessionVar': 'focus',
}) }}
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig', {'tip': 'mautic.focus.noresults.tip'}) }}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'spotlight',
'size': '80'
} %}
</div>
{{ include('@MauticCore/Components/content-item-row.html.twig', {
type: 'default',
eyebrow: 'mautic.focus.onboarding.eyebrow',
heading: 'mautic.focus.onboarding.purpose.heading',
copy: 'mautic.focus.onboarding.purpose.copy',
}) }}
{% set focusFeaturesContainer %}
<div class="row">
<div class="col-sm-6 col-xs-12">
{{ include('@MauticCore/Components/content-item.html.twig', {
type: 'pictogram',
pictogram: 'web--banners',
heading: 'mautic.focus.onboarding.style.bar.heading',
copy: 'mautic.focus.onboarding.style.bar.copy',
}) }}
</div>
<div class="col-sm-6 col-xs-12">
{{ include('@MauticCore/Components/content-item.html.twig', {
type: 'pictogram',
pictogram: 'design-and-development--01',
heading: 'mautic.focus.onboarding.style.modal.heading',
copy: 'mautic.focus.onboarding.style.modal.copy',
}) }}
</div>
<div class="col-sm-6 col-xs-12">
{{ include('@MauticCore/Components/content-item.html.twig', {
type: 'pictogram',
pictogram: 'notifications',
heading: 'mautic.focus.onboarding.style.notification.heading',
copy: 'mautic.focus.onboarding.style.notification.copy',
}) }}
</div>
<div class="col-sm-6 col-xs-12">
{{ include('@MauticCore/Components/content-item.html.twig', {
type: 'pictogram',
pictogram: 'maximize',
heading: 'mautic.focus.onboarding.style.fullpage.heading',
copy: 'mautic.focus.onboarding.style.fullpage.copy',
}) }}
</div>
</div>
{% endset %}
{{ include('@MauticCore/Components/content-group.html.twig', {
heading: 'mautic.focus.onboarding.styles.heading',
childContainer: focusFeaturesContainer,
}) }}
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.focus.onboarding.heading',
subheading: 'mautic.focus.onboarding.subheading',
copy: '',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,276 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}focus{% endblock %}
{% block headerTitle %}{{ item.name }}{% endblock %}
{% block publishStatus %}
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {'entity' : item}) -}}
<div class="label__divider"></div>
{% if item.type is defined and item.type is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: ('mautic.focus.form.type.' ~ item.type),
icon: {
'form': 'ri-survey-fill',
'link': 'ri-link',
'notice': 'ri-information-2-fill'
}[item.type],
color: 'high-contrast',
attributes: {
'data-toggle': 'tooltip',
'data-placement': 'top',
'title': 'mautic.focus.type'|trans
}
}]
} %}
{% endif %}
{% if item.style is defined and item.style is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: ('mautic.focus.style.' ~ item.style),
icon: {
'modal': 'ri-window-2-fill',
'notification': 'ri-notification-3-fill',
'bar': 'ri-layout-bottom-2-fill',
'page': 'ri-macbook-fill'
}[item.style],
color: 'blue',
attributes: {
'data-toggle': 'tooltip',
'data-placement': 'top',
'title': 'mautic.focus.style'|trans
}
}]
} %}
{% endif %}
{% if item.properties.when is defined and item.properties.when is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
icon: {
'immediately': 'ri-flashlight-fill',
'scroll_slight': 'ri-mouse-fill',
'scroll_middle': 'ri-scroll-to-bottom-fill',
'scroll_bottom': 'ri-scroll-to-bottom-fill',
'leave': 'ri-picture-in-picture-exit-fill'
}[item.properties.when],
label: ('mautic.focus.form.when.' ~ item.properties.when ~ '.description'),
color: 'blue',
icon_only: true
}]
} %}
{% endif %}
{% if item.properties.frequency is defined and item.properties.frequency is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
icon: {
'everypage': 'ri-repeat-2-fill',
'once': 'ri-repeat-one-fill',
'q2min': 'ri-reset-left-line',
'q15min': 'replay-15-fill',
'hourly': 'ri-history-fill 24-hours-fill',
'daily': 'ri-calendar-schedule-fill'
}[item.properties.frequency],
label: ('mautic.focus.form.frequency.' ~ item.properties.frequency ~ '.description'),
color: 'blue',
icon_only: true
}]
} %}
{% endif %}
{% if item.properties.timeout is defined and item.properties.timeout is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: item.properties.timeout,
icon: 'ri-timer-fill',
color: 'blue',
attributes: {
'data-toggle': 'tooltip',
'data-placement': 'top',
'title': 'mautic.focus.form.timeout.description'|trans
}
}]
} %}
{% endif %}
{% endblock %}
{% block preHeader %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'item' : item,
'templateButtons' : {
'close' : securityIsGranted('focus:items:view'),
},
'routeBase' : 'focus',
'targetLabel': 'mautic.focus.focus_items'|trans
}
) -}}
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': item.category}) }}
{{ include('@MauticProject/Modules/projects.html.twig') }}
{% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': securityHasEntityAccess(permissions['focus:items:editown'], permissions['focus:items:editother'], item.createdBy),
'clone': permissions['focus:items:create'],
'delete': securityHasEntityAccess(permissions['focus:items:deleteown'], permissions['focus:items:deleteother'], item.createdBy),
},
'routeBase': 'focus',
'langVar': 'focus',
}) -}}
{% endblock %}
{% block content %}
{{ includeScript('plugins/MauticFocusBundle/Assets/js/focus.js') }}
<!-- start: box layout -->
<div class="box-layout">
<!-- left section -->
<div class="col-md-9 height-auto">
<div>
<!-- form detail header -->
{% include '@MauticCore/Helper/description--expanded.html.twig' with {'description': item.description} %}
<!--/ form detail header -->
<!-- form detail collapseable -->
<div class="collapse pr-md pl-md" id="focus-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table table-hover mb-0" data-view-table data-entity-id="{{ item.id }}">
<tbody>
{{ include('@MauticCore/Helper/details.html.twig', {'entity': item}) }}
</tbody>
</table>
</div>
</div>
</div>
<!--/ form detail collapseable -->
</div>
<div>
<!-- form detail collapseable toggler -->
<div class="hr-expand nm">
<span data-toggle="tooltip" title="{{ 'mautic.core.details'|trans }}">
<a href="javascript:void(0)" class="arrow text-secondary collapsed" data-toggle="collapse" data-target="#focus-details"><span class="caret"></span>
{{ 'mautic.core.details'|trans }}
</a>
</span>
</div>
<!--/ form detail collapseable toggler -->
<!-- stats -->
<div class="pa-md">
<div class="row">
<div class="col-sm-12">
{{ include('@MauticCore/Modules/stat--icon.html.twig', {'stats': [
{
'title': 'mautic.focus.details.views',
'value_attr': 'data-focus-total-views-cell',
'value': '<div class="spinner"><i class="ri-loader-3-line ri-spin"></i></div>',
'tooltip': 'mautic.focus.details.views.tooltip',
'icon': 'ri-eye-line',
},
{
'title': 'mautic.focus.details.unique_views',
'value_attr': 'data-focus-total-unique-views-cell',
'value': '<div class="spinner"><i class="ri-loader-3-line ri-spin"></i></div>',
'tooltip': 'mautic.focus.details.unique_views.tooltip',
'icon': 'ri-user-6-line',
}
]}) }}
<div class="panel">
<div class="panel-body box-layout">
<div class="col-xs-4 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
<span class="ri-line-chart-fill"></span>
{{ 'mautic.focus.graph.stats'|trans }}
</h5>
</div>
<div class="col-xs-8 va-m">
{{ include('@MauticCore/Helper/graph_dateselect.html.twig', {'dateRangeForm': dateRangeForm, 'class': 'pull-right'}) }}
</div>
</div>
<div class="d-flex fd-column pt-0 pl-15 pb-15 pr-15 min-h-256">
{{ include('@MauticCore/Helper/chart.html.twig', {'chartData': stats, 'chartType': 'line', 'chartHeight': 300}) }}
</div>
</div>
</div>
</div>
</div>
<!--/ stats -->
{{ customContent('details.stats.graph.below', _context) }}
{% if trackables is defined and trackables is not empty %}
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active">
<a href="#clicks-container" role="tab" data-toggle="tab">
{{ 'mautic.trackable.click_counts'|trans }}
</a>
</li>
</ul>
<!--/ tabs controls -->
<!-- start: tab-content -->
<div class="tab-content pa-md">
<div class="tab-pane active bdr-w-0" id="clicks-container">
{{ include('@MauticPage/Trackable/click_counts.html.twig', {
'trackables': trackables,
'entity': item,
'channel': 'focus',
}) }}
</div>
</div>
<!-- end: tab-content -->
{% endif %}
</div>
</div>
<!--/ left section -->
<!-- right section -->
<div class="col-md-3 bdr-l height-auto">
<!-- form HTML -->
<div class="pa-md">
{% set aboveFoldContent %}
<h4 class="mb-lg fw-b">{{ 'mautic.focus.install.header'|trans }}</h4>
{% include '@MauticCore/Components/pictogram.html.twig' with {
pictogram: 'embed',
size: 64,
color: 'var(--icon-interactive)'
} %}
{% endset %}
{% set belowFoldContent %}
<p class="mt-lg mb-lg">{{ 'mautic.focus.install.description'|trans }}</p>
<input onclick="this.setSelectionRange(0, this.value.length);" type="text" class="form-control" readonly value="&lt;script src=&quot;{{ url('mautic_focus_generate', {'id': item.id}, true) }}&quot; type=&quot;text/javascript&quot; charset=&quot;utf-8&quot; async=&quot;async&quot;&gt;&lt;/script&gt;"/>
{% endset %}
{{ include('@MauticCore/Components/tile.html.twig', {
'type': 'expandable-interactive',
'aboveFoldContent': aboveFoldContent,
'belowFoldContent': belowFoldContent,
}) }}
</div>
<!--/ form HTML -->
<hr class="hr-w-2" style="width:50%">
{# we can leverage data from audit_log table and build activity feed from it #}
<div class="panel shd-none bdr-rds-0 bdr-w-0 mb-0">
<!-- recent activity -->
{{ include('@MauticCore/Helper/recentactivity.html.twig', {'logs': logs}) }}
</div>
</div>
<!--/ right section -->
</div>
<!--/ end: box layout -->
<input type="hidden" name="entityId" id="entityId" value="{{ item.id }}"/>
{% endblock %}

View File

@@ -0,0 +1,411 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}focus{% endblock %}
{% block headerTitle %}
{% if entity.id %}
{{ 'mautic.focus.edit'|trans({'%name%': entity.name|trans}) }}
{% else %}
{{ 'mautic.focus.new'|trans }}
{% endif %}
{% endblock %}
{% block content %}
{{ includeStylesheet('plugins/MauticFocusBundle/Assets/css/focus.css') }}
{{ form_start(form) }}
<!-- start: box layout -->
<div class="box-layout">
<!-- container -->
<div class="col-md-9 height-auto bdr-r pa-md">
<div class="row">
<div class="col-md-6">{{ form_row(form.name) }}</div>
<div class="col-md-6">{{ form_row(form.website) }}</div>
</div>
<div class="row">
<div class="col-md-12">{{ form_row(form.description) }}</div>
</div>
</div>
<div class="col-md-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.isPublished) }}
{{ form_row(form.publishUp) }}
{{ form_row(form.publishDown) }}
<hr />
{% include '@MauticCore/FormTheme/Fields/_utm_tags_fields.html.twig' %}
</div>
</div>
</div>
<div class="hide builder focus-builder animation--slide-in-down">
{% set previewWebsiteMenu %}
<!-- Form to get preview URL -->
<div class="website-placeholder well well-lg col-xs-12">
<div class="row">
<div class="mautibot-image col-xs-3 text-center">
<img class="img-responsive" style="max-height: 125px; margin-left: auto; margin-right: auto;" src="{{ mautibotGetImage('wave') }}"/>
</div>
<div class="col-xs-9 layer-two">
<h4><i class="ri-double-quotes-l"></i> {{ 'mautic.core.noresults.tip'|trans }}
<i class="ri-double-quotes-r"></i></h4>
<p class="mt-md">
{{ 'mautic.focus.website_placeholder'|trans }}
</p>
<div class="input-group">
<input id="websiteUrlPlaceholderInput" disabled type="text" class="form-control" placeholder="https://example.com">
<span class="input-group-btn">
<button class="btn btn-ghost btn-fetch" type="button">{{ 'mautic.focus.fetch_snapshot'|trans }}</button>
</span>
</div>
<div class="help-block hide"></div>
</div>
</div>
</div>
{% endset %}
{% include '@MauticCore/Components/uishell.html.twig' with {
Header: {
ariaLabel: 'mautic.focus.builder',
children: {
HeaderName: {
prefix: entity.name is empty ? '' : 'mautic.focus.focus_item',
children: entity.name|default('mautic.focus.unsaved'),
},
'HeaderNavigation': {
'children': [
{
'HeaderMenuDropdown': {
'menuLinkName': 'mautic.focus.preview',
'renderMenuContent': previewWebsiteMenu,
},
},
],
},
HeaderGlobal: {
children: [
{
'HeaderGlobalAction': {
ariaLabel: 'mautic.core.close.builder',
onClick: 'Mautic.closeFocusBuilder(this);',
renderActionIcon: 'ri-close-line',
className: 'btn-close-builder btn-nospin',
}
}
],
},
},
},
} %}
<div class="builder-content">
<div class="website-preview">
<!-- Viewport switcher -->
<div class="viewport-switcher text-center">
<div class="btn btn-sm btn-success btn-nospin btn-viewport" data-viewport="desktop">
<i class="ri-smartphone-line ri-2x"></i>
</div>
</div>
<!-- Website preview block -->
<div id="websiteScreenshot">
<div class="screenshot-container text-center">
<div class="preview-body center"></div>
<div id="websiteCanvas"></div>
</div>
</div>
</div>
</div>
<!-- Builder -->
<div class="builder-panel builder-panel-focus">
<div class="builder-panel-top">
<p>
</p>
</div>
{% set class = form.type.vars.data is defined and form.type.vars.data is not empty ? 'focus-type-' ~ form.type.vars.data : 'focus-type-all' %}
{% set class = form.style.vars.data is defined and form.style.vars.data is not empty ? class ~ ' focus-style-' ~ form.style.vars.data : class ~ ' focus-style-all' %}
<div class="{{ class }}" style="margin-top: 40px;" id="focusFormContent">
<!-- start focus type -->
<div class="panel panel-default" id="focusType">
<div class="panel-heading">
<h4 class="focus-type-header panel-title">
<a role="button" data-toggle="collapse" href="#focusTypePanel" aria-expanded="true" aria-controls="focusTypePanel">
<i class="ri-focus-2-line"></i> {{ 'mautic.focus.form.type'|trans }}
</a>
</h4>
</div>
<div id="focusTypePanel" class="panel-collapse collapse in" role="tabpanel">
{{ form_widget(form.type) }}
<ul class="list-group mb-0">
<li data-focus-type="form" class="focus-type list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="ri-2x ri-edit-box-line text-interactive"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.form.type.form'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.form.type.form_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-form-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
<li data-focus-type="notice" class="focus-type list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="ri-2x ri-megaphone-line text-warning"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.form.type.notice'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.form.type.notice_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-notice-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
<li data-focus-type="link" class="focus-type list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="ri-2x ri-corner-up-right-line text-info"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.form.type.link'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.form.type.link_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-link-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
</ul>
</div>
<div class="hide" id="focusTypeProperties">
{{ form_row(form.properties.animate) }}
{{ form_row(form.properties.when) }}
{{ form_row(form.properties.timeout) }}
{{ form_row(form.properties.link_activation) }}
{{ form_row(form.properties.frequency) }}
<div class="hidden-focus-type-notice">
{{ form_row(form.properties.stop_after_conversion) }}
</div>
{{ form_row(form.properties.stop_after_close) }}
</div>
</div>
<!-- end focus type -->
<!-- start focus type tab -->
<div class="panel panel-default" id="focusStyle">
<div class="panel-heading">
<h4 class="panel-title focus-style-header">
<a role="button" data-toggle="collapse" href="#focusStylePanel" aria-expanded="true" aria-controls="focusStylePanel">
<i class="ri-mac-line"></i> {{ 'mautic.focus.form.style'|trans }}
</a>
</h4>
</div>
<div id="focusStylePanel" class="panel-collapse collapse" role="tabpanel">
<ul class="list-group mb-0">
<li data-focus-style="bar" class="focus-style visible-focus-style-bar list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="pl-2 ri-2x ri-subtract-line text-interactive"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.style.bar'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.style.bar_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-bar-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
<li data-focus-style="modal" class="focus-style visible-focus-style-modal list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="ri-2x ri-file-list-2-line text-warning"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.style.modal'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.style.modal_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-modal-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
<li data-focus-style="notification" class="focus-style visible-focus-style-notification list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="pl-2 ri-2x ri-information-2-line text-info"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.style.notification'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.style.notification_description'|trans }}</p>
</div>
</div>
</li>
<li class="focus-properties focus-notification-properties list-group-item pl-sm pr-sm" style="display: none;"></li>
<li data-focus-style="page" class="focus-style visible-focus-style-page list-group-item pl-sm pr-sm">
<div class="row">
<div class="col-xs-2">
<i class="pl-2 ri-2x ri-square-line text-danger"></i>
</div>
<div class="col-xs-10">
<h4 class="list-group-heading">{{ 'mautic.focus.style.page'|trans }}</h4>
<p class="list-group-item-heading small">{{ 'mautic.focus.style.page_description'|trans }}</p>
</div>
</div>
</li>
<!-- <li class="focus-properties focus-page-properties list-group-item pl-sm pr-sm" style="display: none;"></li> -->
</ul>
</div>
<div class="hide" id="focusStyleProperties">
<!-- bar type properties -->
<div class="focus-hide visible-focus-style-bar">
{{ form_row(form.properties.bar.allow_hide) }}
{{ form_row(form.properties.bar.push_page) }}
{{ form_row(form.properties.bar.sticky) }}
{{ form_row(form.properties.bar.placement) }}
{{ form_row(form.properties.bar.size) }}
</div>
<!-- modal type properties -->
<div class="focus-hide visible-focus-style-modal">
{{ form_row(form.properties.modal.placement) }}
</div>
<!-- notifications type properties -->
<div class="focus-hide visible-focus-style-notification">
{{ form_row(form.properties.notification.placement) }}
</div>
<!-- page type properties -->
<!-- <div class="focus-hide visible-focus-style-page"></div> -->
</div>
</div>
<!-- end focus style -->
<!-- start focus colors -->
<div class="panel panel-default" id="focusColors">
<div class="panel-heading">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" href="#focusColorsPanel" aria-expanded="true" aria-controls="focusColorsPanel">
<i class="ri-paint-brush-line"></i> {{ 'mautic.focus.tab.focus_colors'|trans }}
</a>
</h4>
</div>
<div id="focusColorsPanel" class="panel-collapse collapse" role="tabpanel">
<div class="panel-body pa-xs">
<div class="row">
<div class="form-group col-xs-12 ">
{{ form_label(form.properties.colors.primary) }}
<div class="input-group">
{{ form_widget(form.properties.colors.primary) }}
<span class="input-group-btn">
<button data-dropper="focus_properties_colors_primary" class="btn btn-ghost btn-nospin btn-dropper" type="button"><i class="ri-dropper-line"></i></button>
</span>
</div>
<div class="mt-xs site-color-list hide" id="primary_site_colors"></div>
{{ form_errors(form.properties.colors.primary) }}
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 ">
{{ form_label(form.properties.colors.text) }}
<div class="input-group">
{{ form_widget(form.properties.colors.text) }}
<span class="input-group-btn">
<button data-dropper="focus_properties_colors_text" class="btn btn-ghost btn-nospin btn-dropper" type="button"><i class="ri-dropper-line"></i></button>
</span>
</div>
<div class="mt-xs site-color-list hide" id="text_site_colors"></div>
{{ form_errors(form.properties.colors.text) }}
</div>
</div>
<div class="hidden-focus-type-notice">
<div class="row">
<div class="form-group col-xs-12 ">
{{ form_label(form.properties.colors.button) }}
<div class="input-group">
{{ form_widget(form.properties.colors.button) }}
<span class="input-group-btn">
<button data-dropper="focus_properties_colors_button" class="btn btn-ghost btn-nospin btn-dropper" type="button"><i class="ri-dropper-line"></i></button>
</span>
</div>
<div class="mt-xs site-color-list hide" id="button_site_colors"></div>
{{ form_errors(form.properties.colors.button) }}
</div>
</div>
<div class="row">
<div class="form-group col-xs-12 ">
{{ form_label(form.properties.colors.button_text) }}
<div class="input-group">
{{ form_widget(form.properties.colors.button_text) }}
<span class="input-group-btn">
<button data-dropper="focus_properties_colors_button_text" class="btn btn-ghost btn-nospin btn-dropper" type="button"><i class="ri-dropper-line"></i></button>
</span>
</div>
<div class="mt-xs site-color-list hide" id="button_text_site_colors"></div>
{{ form_errors(form.properties.colors.button_text) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- end focus colors -->
<!-- start focus content -->
<div class="panel panel-default" id="focusContent">
<div class="panel-heading">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" href="#focusContentPanel" aria-expanded="true" aria-controls="focusContentPanel">
<i class="ri-newspaper-line"></i> {{ 'mautic.focus.tab.focus_content'|trans }}
</a>
</h4>
</div>
<div id="focusContentPanel" class="panel-collapse collapse" role="tabpanel">
<div class="panel-body pa-xs">
{{ form_row(form.html_mode) }}
{{ form_row(form.editor) }}
{{ form_row(form.html) }}
{{ form_row(form.properties.content.headline) }}
<div class="hidden-focus-style-bar">
{{ form_row(form.properties.content.tagline) }}
</div>
{{ form_row(form.properties.content.font) }}
<!-- form type properties -->
<div class="focus-hide visible-focus-type-form">
<div class="col-sm-12" id="focusFormAlert" data-hide-on='{"focus_html_mode_0":"checked"}'>
<div class="alert alert-info">
{{ 'mautic.focus.form_token.instructions'|trans|purify }}
</div>
</div>
{{ form_row(form.form) }}
</div>
<!-- link type properties -->
<div class="focus-hide visible-focus-type-link">
{{ form_row(form.properties.content.link_text) }}
{{ form_row(form.properties.content.link_url) }}
{{ form_row(form.properties.content.link_new_window) }}
</div>
{{ form_row(form.properties.content.css) }}
</div>
</div>
</div>
<!-- end focus content -->
</div>
</div>
</div>
{{ form_end(form) }}
{{ includeScript('plugins/MauticFocusBundle/Assets/js/focus.js') }}
{% endblock %}

View File

@@ -0,0 +1,66 @@
{#
#}
{% set isIndex = 'index' == tmpl ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}focus{% endblock %}
{% block headerTitle %}{{ 'mautic.focus'|trans }}{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'searchHelp': 'mautic.focus.help.searchcommands',
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['focus:items:create'],
},
'routeBase': 'focus',
},
'bulk_actions': {
'routeBase': 'focus',
'templateButtons': {
'delete': permissions['focus:items:delete'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.active',
'tooltip': 'mautic.core.searchcommand.ispublished.description',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.inactive',
'tooltip': 'mautic.core.searchcommand.isunpublished.description',
'icon': 'ri-close-line'
},
{
'search': 'mautic.core.searchcommand.isuncategorized',
'label': 'mautic.core.form.uncategorized',
'tooltip': 'mautic.core.searchcommand.isuncategorized.description',
'icon': 'ri-folder-unknow-line'
},
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
}
]
}) }}
<div class="page-list">
{% endif %}
{{ include('@MauticFocus/Focus/_list.html.twig') }}
{% if isIndex %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{#
Used in the Campaign Builder
1. Add Decision for "Visits Page"
2. Add Action for "Show Focus Item"
#}
{% block focusshow_list_row %}
{{ includeScript('plugins/MauticFocusBundle/Assets/js/focus.js') }}
<div class="row">
<div class="col-xs-12">
{{ form_row(form.focus) }}
</div>
<div class="col-xs-12 mt-lg">
<div class="mt-3">
{{ form_row(form.newFocusButton) }}
{{ form_row(form.editFocusButton) }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% if showMore is defined %}
<a href="{{ url('mautic_focus_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_focus_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
<span class="fw-sb">{{ item.name }}</span>
<span class="ml-4 mr-sm">#{{ item.getId() }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'active',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
{% endif %}

View File

@@ -0,0 +1,27 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Symfony\Component\Form\FormBuilderInterface;
class FocusPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->addStandardPermissions('categories');
$this->addExtendedPermissions('items');
}
public function getName(): string
{
return 'focus';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('focus', 'categories', $builder, $data);
$this->addExtendedFormFields('focus', 'items', $builder, $data);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class FocusApiControllerTest extends MauticMysqlTestCase
{
/**
* @var array<string,mixed>
*/
private array $testPayload = [
'name' => 'test',
'type' => 'notice',
'website' => 'http://',
'style' => 'bar',
'htmlMode' => 1,
'html' => '<div><strong style="color:red">html mode enabled</strong></div>',
'properties' => [
'bar' => [
'allow_hide' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'animate' => 1,
'link_activation' => 1,
'colors' => [
'primary' => '27184e',
],
'content' => [
'headline' => '',
'font' => 'Arial, Helvetica, sans-serif',
],
'when' => 'immediately',
'frequency' => 'everypage',
'stop_after_conversion' => 1,
],
];
public function testFocusApiNew(): void
{
// Create a focus item.
$this->client->request(Request::METHOD_POST, '/api/focus/new', $this->testPayload);
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode(), $response->getContent());
$createdItem = json_decode($response->getContent(), true)['focus'];
Assert::assertNotEmpty($createdItem['id'], $response->getContent());
Assert::assertSame($this->testPayload['name'], $createdItem['name'], $response->getContent());
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\HttpFoundation\Request;
class FocusAjaxControllerFunctionalTest extends MauticMysqlTestCase
{
public function testViewsCount(): void
{
/** @var FocusModel $focusModel */
$focusModel = static::getContainer()->get('mautic.focus.model.focus');
$focus = $this->createFocus('popup');
$focusModel->saveEntity($focus);
$leads = [
$this->createLead(),
$this->createLead(),
];
$focusModel->addStat($focus, Stat::TYPE_NOTIFICATION, null, $leads[0]);
$focusModel->addStat($focus, Stat::TYPE_NOTIFICATION, null, $leads[0]);
$focusModel->addStat($focus, Stat::TYPE_NOTIFICATION, null, $leads[1]);
$this->client->xmlHttpRequest(Request::METHOD_GET, "/s/ajax?action=plugin:focus:getViewsCount&focusId={$focus->getId()}");
$response = $this->client->getResponse();
$this->assertTrue($response->isOk());
$this->assertSame([
'success' => 1,
'views' => 3,
'uniqueViews' => 2,
], json_decode($response->getContent(), true));
}
public function testClickThroughCount(): void
{
/** @var FocusModel $focusModel */
$focusModel = static::getContainer()->get('mautic.focus.model.focus');
$focus = $this->createFocus('popup');
$focusModel->saveEntity($focus);
$lead1 = $this->createLead();
$lead2 = $this->createLead();
$focusModel->addStat($focus, Stat::TYPE_CLICK, $this->createHit($lead1), $lead1);
$focusModel->addStat($focus, Stat::TYPE_CLICK, $this->createHit($lead1), $lead1);
$focusModel->addStat($focus, Stat::TYPE_CLICK, $this->createHit($lead2), $lead2);
$this->client->xmlHttpRequest(Request::METHOD_GET, "/s/ajax?action=plugin:focus:getClickThroughCount&focusId={$focus->getId()}");
$response = $this->client->getResponse();
$this->assertTrue($response->isOk());
$this->assertSame([
'success' => 1,
'clickThrough' => 2,
], json_decode($response->getContent(), true));
}
private function createHit(Lead $lead): Hit
{
$hit = new Hit();
$hit->setLead($lead);
return $hit;
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
return $focus;
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setFirstname('Contact');
$lead->setEmail('test@test.com');
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class FocusControllerTest extends MauticMysqlTestCase
{
public function testIndexActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/focus');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testNewActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/focus/new');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testRecentActivityFeedOnFocusDetailsPage(): void
{
$focus = new Focus();
$focus->setName('Test Focus');
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
/** @var FocusModel $focusModel */
$focusModel = static::getContainer()->get('mautic.focus.model.focus');
$focusModel->saveEntity($focus);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_GET, '/s/focus/edit/'.$focus->getId());
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('focus_buttons_apply')->form();
$form['focus[isPublished]']->setValue('0');
$this->client->submit($form);
$crawler = $this->client->request(Request::METHOD_GET, '/s/focus/view/'.$focus->getId());
$this->assertResponseIsSuccessful();
$translator = self::getContainer()->get('translator');
$this->assertStringContainsString($translator->trans('mautic.core.recent.activity'), $this->client->getResponse()->getContent());
$this->assertCount(2, $crawler->filterXPath('//ul[contains(@class, "media-list-feed")]/li'));
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Symfony\Component\HttpFoundation\Request;
class FocusPublicControllerFunctionalTest extends MauticMysqlTestCase
{
public function testGenerateFocusItemScript(): void
{
/** @var FocusModel $focusModel */
$focusModel = static::getContainer()->get('mautic.focus.model.focus');
$focus = $this->createFocus('popup');
$focusModel->saveEntity($focus);
$this->client->request(Request::METHOD_GET, "/focus/{$focus->getId()}.js");
$response = $this->client->getResponse();
$this->assertTrue($response->isOk());
$this->assertNotEmpty($response->getContent());
}
public function testInactiveFocusItemScript(): void
{
/** @var FocusModel $focusModel */
$focusModel = static::getContainer()->get('mautic.focus.model.focus');
$focus = $this->createFocus('popup');
$focus->setIsPublished(false);
$focusModel->saveEntity($focus);
$this->client->request(Request::METHOD_GET, "/focus/{$focus->getId()}.js");
$response = $this->client->getResponse();
$this->assertTrue($response->isNotFound());
$this->assertEmpty($response->getContent());
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
return $focus;
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Entity;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
class StatRepositoryFunctionalTest extends MauticMysqlTestCase
{
private FocusModel $focusModel;
protected function setUp(): void
{
parent::setUp();
$this->focusModel = static::$kernel->getContainer()->get('mautic.focus.model.focus');
$this->setTestsData($this->createLead(), $this->focusModel);
}
public function testGetStatsViewByLead(): void
{
$this->assertCount(5, $this->focusModel->getStatRepository()->getStatsViewByLead());
}
public function testGetStatsClickByLead(): void
{
$this->assertCount(2, $this->focusModel->getStatRepository()->getStatsClickByLead());
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setFirstname('Contact');
$lead->setEmail('test@test.com');
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
private function setTestsData(Lead $lead, FocusModel $focusModel): void
{
$focusPopupA = $this->createFocus('popup focus A');
$focusPopupB = $this->createFocus('popup focus B');
$focusPopupC = $this->createFocus('popup focus C');
$focusBarA = $this->createFocus('bar focus A');
$focusBarB = $this->createFocus('bar focus B');
$this->focusModel->saveEntity($focusPopupA);
$this->focusModel->saveEntity($focusPopupB);
$this->focusModel->saveEntity($focusPopupC);
$this->focusModel->saveEntity($focusBarA);
$this->focusModel->saveEntity($focusBarB);
$hitPopupA = new Hit();
$hitPopupA->setLead($lead);
$hitBarB = new Hit();
$hitBarB->setLead($lead);
$this->focusModel->addStat($focusPopupA, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusPopupB, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusPopupB, Stat::TYPE_CLICK, $hitPopupA, $lead);
$this->focusModel->addStat($focusPopupC, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusBarA, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusBarA, Stat::TYPE_CLICK, $hitBarB, $lead);
$this->focusModel->addStat($focusBarB, Stat::TYPE_NOTIFICATION, null, $lead);
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
return $focus;
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\EventListener;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
class LeadSubscriberFunctionalTest extends MauticMysqlTestCase
{
private Lead $lead;
private FocusModel $focusModel;
protected function setUp(): void
{
parent::setUp();
$this->focusModel = static::getContainer()->get('mautic.focus.model.focus');
$this->lead = $this->createLead();
$this->setTestsData($this->lead, $this->focusModel);
}
public function testSearchPhraseInNameFocusStat(): void
{
$this->assertCount(3, $this->searchPhrase('bar', $this->lead, $this->focusModel));
$this->assertCount(4, $this->searchPhrase('popup', $this->lead, $this->focusModel));
$this->assertCount(2, $this->searchPhrase('popup focus B', $this->lead, $this->focusModel));
}
public function testSearchPhraseInTypeFocusStat(): void
{
$this->assertCount(2, $this->searchPhrase('click', $this->lead, $this->focusModel));
$this->assertCount(5, $this->searchPhrase('view', $this->lead, $this->focusModel));
}
/**
* @return array<string, mixed>
*/
private function searchPhrase(string $phrase, Lead $lead, FocusModel $focusModel): array
{
$searchViewStats = $focusModel->getStatRepository()->getStatsViewByLead((int) $lead->getId(), ['search'=>$phrase]);
$searchClickStats = $focusModel->getStatRepository()->getStatsClickByLead((int) $lead->getId(), ['search'=>$phrase]);
return array_merge($searchViewStats, $searchClickStats);
}
private function setTestsData(Lead $lead, FocusModel $focusModel): void
{
$focusPopupA = $this->createFocus('popup focus A');
$focusPopupB = $this->createFocus('popup focus B');
$focusPopupC = $this->createFocus('popup focus C');
$focusBarA = $this->createFocus('bar focus A');
$focusBarB = $this->createFocus('bar focus B');
$this->focusModel->saveEntity($focusPopupA);
$this->focusModel->saveEntity($focusPopupB);
$this->focusModel->saveEntity($focusPopupC);
$this->focusModel->saveEntity($focusBarA);
$this->focusModel->saveEntity($focusBarB);
$hitPopupA = new Hit();
$hitPopupA->setLead($lead);
$hitBarB = new Hit();
$hitBarB->setLead($lead);
$this->focusModel->addStat($focusPopupA, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusPopupB, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusPopupB, Stat::TYPE_CLICK, $hitPopupA, $lead);
$this->focusModel->addStat($focusPopupC, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusBarA, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focusBarA, Stat::TYPE_CLICK, $hitBarB, $lead);
$this->focusModel->addStat($focusBarB, Stat::TYPE_NOTIFICATION, null, $lead);
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
return $focus;
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setFirstname('Contact');
$lead->setEmail('test@test.com');
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\EventListener;
use Mautic\CoreBundle\Tests\CommonMocks;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Entity\StatRepository;
use MauticPlugin\MauticFocusBundle\EventListener\LeadSubscriber;
use MauticPlugin\MauticFocusBundle\FocusEventTypes;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Routing\RouterInterface;
class LeadSubscriberTest extends CommonMocks
{
/**
* @var Translator|MockObject
*/
private MockObject $translator;
/**
* @var RouterInterface|MockObject
*/
private MockObject $router;
/**
* @var FocusModel|(FocusModel&MockObject)|MockObject
*/
private MockObject $focusModel;
/**
* @var StatRepository|(StatRepository&MockObject)|MockObject
*/
private MockObject $statRepository;
/**
* @var string
*/
private const EVENT_TYPE_VIEW_NAME = 'Focus view';
/**
* @var string
*/
private const EVENT_TYPE_CLICK_NAME = 'Focus click';
/**
* @var string
*/
private const FOCUS_NAME = 'test Focus Item';
protected function setUp(): void
{
$this->translator = $this->createMock(Translator::class);
$this->router = $this->createMock(RouterInterface::class);
$this->focusModel = $this->createMock(FocusModel::class);
$this->statRepository = $this->createMock(StatRepository::class);
$matcher = $this->any();
$this->translator->expects($matcher)
->method('trans')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('mautic.focus.event.view', $parameters[0]);
return self::EVENT_TYPE_VIEW_NAME;
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('mautic.focus.event.click', $parameters[0]);
return self::EVENT_TYPE_CLICK_NAME;
}
});
}
/**
* Make sure that on timeline entry is created for a lead
* that was displayed Focus Item.
*/
public function testShowFocusItem(): void
{
$lead = $this->getLead();
$date = new \DateTime();
$this->mockFocusModelGetStatsByLead(Stat::TYPE_NOTIFICATION, self::FOCUS_NAME, 'getStatsViewByLead', $date);
$timelineEvent = $this->getTimelineEvent(
FocusEventTypes::FOCUS_ON_VIEW, self::EVENT_TYPE_VIEW_NAME, self::FOCUS_NAME, $date, $lead
);
$leadEvent = new LeadTimelineEvent($lead);
$subscriber = new LeadSubscriber(
$this->translator,
$this->router,
$this->focusModel
);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber($subscriber);
$dispatcher->dispatch($leadEvent, LeadEvents::TIMELINE_ON_GENERATE);
$this->assertSame([$timelineEvent], $leadEvent->getEvents());
}
public function testShowFocusItemWhenNoLead(): void
{
$date = new \DateTime();
$this->mockFocusModelGetStatsByLead(Stat::TYPE_NOTIFICATION, self::FOCUS_NAME, 'getStatsViewByLead', $date);
$timelineEvent = $this->getTimelineEvent(
FocusEventTypes::FOCUS_ON_VIEW, self::EVENT_TYPE_VIEW_NAME, self::FOCUS_NAME, $date
);
$leadEvent = new LeadTimelineEvent();
$subscriber = new LeadSubscriber(
$this->translator,
$this->router,
$this->focusModel
);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber($subscriber);
$dispatcher->dispatch($leadEvent, LeadEvents::TIMELINE_ON_GENERATE);
$this->assertSame([$timelineEvent], $leadEvent->getEvents());
}
/**
* Make sure that on timeline entry is created for a lead
* that was clicked Focus Item.
*/
public function testClickFocusItem(): void
{
$lead = $this->getLead();
$date = new \DateTime();
$this->mockFocusModelGetStatsByLead(Stat::TYPE_CLICK, self::FOCUS_NAME, 'getStatsClickByLead', $date);
$timelineEvent = $this->getTimelineEvent(
FocusEventTypes::FOCUS_ON_CLICK, self::EVENT_TYPE_CLICK_NAME, self::FOCUS_NAME, $date, $lead
);
$leadEvent = new LeadTimelineEvent($lead);
$subscriber = new LeadSubscriber(
$this->translator,
$this->router,
$this->focusModel
);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber($subscriber);
$dispatcher->dispatch($leadEvent, LeadEvents::TIMELINE_ON_GENERATE);
$this->assertSame([$timelineEvent], $leadEvent->getEvents());
}
public function testClickFocusItemWhenNoLead(): void
{
$date = new \DateTime();
$this->mockFocusModelGetStatsByLead(Stat::TYPE_CLICK, self::FOCUS_NAME, 'getStatsClickByLead', $date);
$timelineEvent = $this->getTimelineEvent(
FocusEventTypes::FOCUS_ON_CLICK, self::EVENT_TYPE_CLICK_NAME, self::FOCUS_NAME, $date
);
$leadEvent = new LeadTimelineEvent();
$subscriber = new LeadSubscriber(
$this->translator,
$this->router,
$this->focusModel
);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber($subscriber);
$dispatcher->dispatch($leadEvent, LeadEvents::TIMELINE_ON_GENERATE);
$this->assertSame([$timelineEvent], $leadEvent->getEvents());
}
private function mockFocusModelGetStatsByLead(string $statType, string $focusName, string $method, \DateTime $date): void
{
$stats = [
'results'=> [
[
'id' => 1,
'type' => $statType,
'date_added' => $date,
'focus_id' => 1,
'focus_name' => $focusName,
],
],
'total'=> 1,
];
$this->statRepository->method($method)->willReturn($stats);
$this->focusModel->method('getStatRepository')->willReturn($this->statRepository);
}
private function getLead(): Lead
{
$lead = new Lead();
$lead->setId(1);
return $lead;
}
/**
* @return array<string, mixed>
*/
private function getTimelineEvent(string $eventType, string $eventTypeName, string $focusName, \DateTime $date, ?Lead $lead=null): array
{
$leadEventLogId = 1;
return [
'event' => $eventType,
'eventId' => $eventType.'.'.$leadEventLogId,
'eventLabel' => [
'label' => $focusName,
'href' => '',
],
'eventType' => $eventTypeName,
'timestamp' => $date,
'icon' => 'ri-search-line',
'contactId' => $lead?->getId(),
];
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\EventListener;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\ReportBundle\Entity\Report;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\EventListener\ReportSubscriber;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
final class ReportSubscriberFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
parent::setUp();
}
public function testGenerateFocusItemReportWithAllAvailableColumns(): void
{
$this->fillDatabase();
$report = new Report();
$report->setName('Focus Stats Report');
$report->setSource(ReportSubscriber::CONTEXT_FOCUS_STATS);
$report->setColumns([ReportSubscriber::PREFIX_FOCUS.'.name', ReportSubscriber::PREFIX_FOCUS.'.description', ReportSubscriber::PREFIX_FOCUS.'.focus_type', ReportSubscriber::PREFIX_FOCUS.'.style', ReportSubscriber::PREFIX_STATS.'.type', ReportSubscriber::PREFIX_TRACKABLES.'.hits', ReportSubscriber::PREFIX_TRACKABLES.'.unique_hits', ReportSubscriber::PREFIX_REDIRECTS.'.url',
]);
$this->em->persist($report);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_GET, "/s/reports/view/{$report->getId()}");
$this->assertTrue($this->client->getResponse()->isOk());
// get table with id=reportTable
$crawlerTable = $crawler->filter('#reportTable');
// remove first line of table (column names)
$table = array_slice($this->domTableToArray($crawlerTable), 1);
// remove last line of table (unnecessary part generated by js)
array_pop($table);
$this->assertSame([
['1', 'FocusItem1', 'doesAbc', 'link', 'modal', 'click', '1', '1', 'http://example1.com'],
['2', 'FocusItem1', 'doesAbc', 'link', 'modal', 'view', '3', '2', 'http://example1.com'],
['3', 'FocusItem2', 'doesAbcd', 'link', 'modal', 'click', '1', '1', 'http://example2.com'],
['4', 'FocusItem2', 'doesAbcd', 'link', 'modal', 'view', '1', '1', 'http://example2.com'],
], $table);
}
public function testGenerateFocusItemReportFocusLeadsColumns(): void
{
$this->fillDatabase();
$report = new Report();
$report->setName('Focus Leads Report');
$report->setSource(ReportSubscriber::CONTEXT_FOCUS_LEADS);
$report->setColumns(
[
ReportSubscriber::PREFIX_LEADS.'.email',
ReportSubscriber::PREFIX_FOCUS.'.name',
ReportSubscriber::PREFIX_FOCUS.'.description',
ReportSubscriber::PREFIX_FOCUS.'.focus_type',
ReportSubscriber::PREFIX_FOCUS.'.style',
ReportSubscriber::PREFIX_STATS.'.type',
ReportSubscriber::PREFIX_TRACKABLES.'.hits',
ReportSubscriber::PREFIX_REDIRECTS.'.url',
]
);
$this->em->persist($report);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_GET, "/s/reports/view/{$report->getId()}");
$this->assertTrue($this->client->getResponse()->isOk());
// get table with id=reportTable
$crawlerTable = $crawler->filter('#reportTable');
// remove first line of table (column names)
$table = array_slice($this->domTableToArray($crawlerTable), 1);
// remove last line of table (unnecessary part generated by js)
array_pop($table);
$this->assertSame([
['1', 'lead.1@example.com', 'FocusItem1', 'doesAbc', 'link', 'modal', 'click', '1', 'http://example1.com'],
['2', 'lead.1@example.com', 'FocusItem1', 'doesAbc', 'link', 'modal', 'view', '2', 'http://example1.com'],
['3', 'lead.2@example.com', 'FocusItem1', 'doesAbc', 'link', 'modal', 'view', '1', 'http://example1.com'],
['4', 'lead.2@example.com', 'FocusItem2', 'doesAbcd', 'link', 'modal', 'click', '1', 'http://example2.com'],
['5', 'lead.2@example.com', 'FocusItem2', 'doesAbcd', 'link', 'modal', 'view', '1', 'http://example2.com'],
], $table);
}
private function fillDatabase(): void
{
$lead1= $this->createContact('lead.1@example.com');
$lead2= $this->createContact('lead.2@example.com');
$focus1 = $this->createFocusItem('FocusItem1', 'doesAbc', 'link', 'modal');
$focus2 = $this->createFocusItem('FocusItem2', 'doesAbcd', 'link', 'modal');
$focus3 = $this->createFocusItem('FocusItem3', 'doesAbcde', 'link', 'modal');
$this->em->flush();
$date = new \DateTime();
$this->createFocusStats('click', $focus1, $lead1, $date);
$this->createFocusStats('click', $focus2, $lead2, $date);
$this->createFocusStats('view', $focus1, $lead1, $date);
$this->createFocusStats('view', $focus1, $lead1, $date);
$this->createFocusStats('view', $focus1, $lead2, $date);
$this->createFocusStats('view', $focus2, $lead2, $date);
/** @var int $focusId1 */
$focusId1 = $focus1->getId();
/** @var int $focusId2 */
$focusId2 = $focus2->getId();
$this->createTrackableAndRedirects('http://example1.com', $focusId1, 1, 1);
$this->createTrackableAndRedirects('http://example2.com', $focusId2, 1, 1);
$this->createTrackableAndRedirects('http://example2.com', $focusId2);
$this->em->flush();
$this->em->clear();
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
private function createFocusItem(string $name, string $description, string $focusType, string $style): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setDescription($description);
$focus->setType($focusType);
$focus->setStyle($style);
$this->em->persist($focus);
return $focus;
}
private function createFocusStats(string $type, Focus $focus, Lead $lead, \DateTime $dateAdded): void
{
$focusStats = new Stat();
$focusStats->setType($type);
$focusStats->setFocus($focus);
$focusStats->setLead($lead);
$focusStats->setDateAdded($dateAdded);
$this->em->persist($focusStats);
}
private function createTrackableAndRedirects(string $url, int $channelId, int $hits = 0, int $uniqueHits = 0): void
{
$redirect = new Redirect();
$redirect->setRedirectId(uniqid());
$redirect->setUrl($url);
$redirect->setHits($hits);
$redirect->setUniqueHits($uniqueHits);
$this->em->persist($redirect);
$trackable = new Trackable();
$trackable->setChannelId($channelId);
$trackable->setChannel('focus');
$trackable->setHits($hits);
$trackable->setUniqueHits($uniqueHits);
$trackable->setRedirect($redirect);
$this->em->persist($trackable);
}
/**
* @return array<int,array<int,mixed>>
*/
private function domTableToArray(Crawler $crawler): array
{
return $crawler->filter('tr')->each(fn ($tr) => $tr->filter('td')->each(fn ($td) => trim($td->text())));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Form\Type;
use MauticPlugin\MauticFocusBundle\Form\Type\ContentType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormBuilderInterface;
class ContentTypeTest extends TestCase
{
/**
* @var mixed|\PHPUnit\Framework\MockObject\MockObject|FormBuilderInterface
*/
private \PHPUnit\Framework\MockObject\MockObject $formBuilder;
protected function setUp(): void
{
$this->formBuilder = $this->createMock(FormBuilderInterface::class);
}
public function testBuilderForm(): void
{
$this->formBuilder->expects(self::exactly(7))->method('add')->willReturnSelf();
$options = [];
$contentType = new ContentType();
$contentType->buildForm($this->formBuilder, $options);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\ProjectBundle\Entity\Project;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use PHPUnit\Framework\Assert;
class FocusControllerTest extends MauticMysqlTestCase
{
public function testFocusWithProject(): void
{
$focus = new Focus();
$focus->setName('Test Focus');
$focus->setType('notice');
$focus->setStyle('bar');
$this->em->persist($focus);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/focus/edit/'.$focus->getId());
$form = $crawler->selectButton('Save')->form();
$form['focus[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedFocus = $this->em->find(Focus::class, $focus->getId());
Assert::assertSame($project->getId(), $savedFocus->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Functional\Controller;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
final class FocusProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$focusAlpha = $this->createFocus('Focus Alpha');
$focusBeta = $this->createFocus('Focus Beta');
$this->createFocus('Focus Gamma');
$this->createFocus('Focus Delta');
$focusAlpha->addProject($projectOne);
$focusAlpha->addProject($projectTwo);
$focusBeta->addProject($projectTwo);
$focusBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/focus', '/s/focus']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['Focus Alpha', 'Focus Beta'],
'unexpectedEntities' => ['Focus Gamma', 'Focus Delta'],
];
yield 'search by one project AND focus name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Focus Beta'],
'unexpectedEntities' => ['Focus Alpha', 'Focus Gamma', 'Focus Delta'],
];
yield 'search by one project OR focus name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Focus Alpha', 'Focus Beta', 'Focus Gamma'],
'unexpectedEntities' => ['Focus Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Focus Gamma', 'Focus Delta'],
'unexpectedEntities' => ['Focus Alpha', 'Focus Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Focus Beta'],
'unexpectedEntities' => ['Focus Alpha', 'Focus Gamma', 'Focus Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Focus Gamma', 'Focus Delta'],
'unexpectedEntities' => ['Focus Alpha', 'Focus Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Focus Alpha', 'Focus Beta'],
'unexpectedEntities' => ['Focus Gamma', 'Focus Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Focus Alpha', 'Focus Gamma', 'Focus Delta'],
'unexpectedEntities' => ['Focus Beta'],
];
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('notice');
$focus->setStyle('bar');
$this->em->persist($focus);
return $focus;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Redirect;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class PublicControllerTest extends MauticMysqlTestCase
{
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
public function testGenerateActionWithContactTokenInLinkUrl(): void
{
$linkUrl = 'https://{contactfield=site_url}/tour';
$focus = new Focus();
$focus->setName('Test');
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'content' => [
'headline' => '',
'link_text' => 'Link text',
'link_url' => $linkUrl,
'font' => 'Arial, Helvetica, sans-serif',
'link_new_window' => 1,
],
'when' => 'immediately',
'modal' => [
'placement' => 'top',
],
'frequency' => 'everypage',
'colors' => [
'primary' => '#4e5d9d',
'text' => '#000000',
'button' => '#fdb933',
'button_text' => '#ffffff',
],
]);
$this->em->persist($focus);
$this->em->flush();
$this->em->clear();
$this->client->request(Request::METHOD_GET, sprintf('/focus/%s.js', $focus->getId()));
$content = $this->client->getResponse()->getContent();
$redirects = $this->em->getRepository(Redirect::class)->findAll();
Assert::assertCount(1, $redirects);
/** @var Redirect $redirect */
$redirect = reset($redirects);
Assert::assertSame($linkUrl, $redirect->getUrl());
$url = $this->router->generate('mautic_url_redirect', ['redirectId' => $redirect->getRedirectId()], UrlGeneratorInterface::ABSOLUTE_URL);
$twig = $this->getContainer()->get('twig');
if (!$twig->hasExtension(\Twig\Extension\EscaperExtension::class)) {
$twig->addExtension(new \Twig\Extension\EscaperExtension());
}
$url = $twig->getRuntime(\Twig\Runtime\EscaperRuntime::class)->escape($url, 'js');
Assert::assertStringContainsString($url, $content);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Model;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Entity\Stat;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
class FocusModelFunctionalTest extends MauticMysqlTestCase
{
private Lead $lead;
private FocusModel $focusModel;
protected function setUp(): void
{
parent::setUp();
$this->focusModel = static::getContainer()->get('mautic.focus.model.focus');
$this->lead = $this->createLead();
}
public function testGetStats(): void
{
$focusPopupA = $this->createFocus('popup focus A');
$focusStatExpected = $this->setTestsData($this->lead, $focusPopupA);
$to = new \DateTime('+1 day');
$from = new \DateTime('-1 month');
$focusStat = $this->focusModel->getStats($focusPopupA, null, $from, $to);
$focusViewsCount = array_sum($focusStat['datasets'][0]['data']);
$focusClickCount = array_sum($focusStat['datasets'][1]['data']);
$this->assertEquals($focusStatExpected['view'], $focusViewsCount);
$this->assertEquals($focusStatExpected['click'], $focusClickCount);
}
/**
* @return array<string, int>
*/
private function setTestsData(Lead $lead, Focus $focus): array
{
$hitPopupA = new Hit();
$hitPopupA->setLead($lead);
$this->focusModel->addStat($focus, Stat::TYPE_NOTIFICATION, null, $lead);
$this->focusModel->addStat($focus, Stat::TYPE_CLICK, $hitPopupA, $lead);
$this->focusModel->addStat($focus, Stat::TYPE_CLICK, $hitPopupA, $lead);
$this->focusModel->addStat($focus, Stat::TYPE_CLICK, $hitPopupA, $lead);
$this->focusModel->addStat($focus, Stat::TYPE_CLICK, $hitPopupA, $lead);
return ['view' => 1, 'click' => 4];
}
private function createFocus(string $name): Focus
{
$focus = new Focus();
$focus->setName($name);
$focus->setType('link');
$focus->setStyle('modal');
$focus->setProperties([
'bar' => [
'allow_hide' => 1,
'push_page' => 1,
'sticky' => 1,
'size' => 'large',
'placement' => 'top',
],
'modal' => [
'placement' => 'top',
],
'notification' => [
'placement' => 'top_left',
],
'page' => [],
'animate' => 0,
'link_activation' => 1,
'colors' => [
'primary' => '4e5d9d',
'text' => '000000',
'button' => 'fdb933',
'button_text' => 'ffffff',
],
'content' => [
'headline' => null,
'tagline' => null,
'link_text' => null,
'link_url' => null,
'link_new_window' => 1,
'font' => 'Arial, Helvetica, sans-serif',
'css' => null,
],
'when' => 'immediately',
'timeout' => null,
'frequency' => 'everypage',
'stop_after_conversion' => 1,
]);
$this->focusModel->saveEntity($focus);
return $focus;
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setFirstname('Contact');
$lead->setEmail('test@test.com');
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\FormBundle\Model\FormModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Model\TrackableModel;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvokedCount;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class FocusModelTest extends TestCase
{
/**
* @var ContactTracker|MockObject
*/
private MockObject $contactTracker;
/**
* @var MockObject|EventDispatcherInterface
*/
private MockObject $dispatcher;
/**
* @var FormModel|MockObject
*/
private MockObject $formModel;
/**
* @var FieldModel|MockObject
*/
private MockObject $leadFieldModel;
/**
* @var Environment|mixed|MockObject
*/
private MockObject $twig;
/**
* @var TrackableModel|mixed|MockObject
*/
private MockObject $trackableModel;
protected function setUp(): void
{
$this->formModel = $this->createMock(FormModel::class);
$this->trackableModel = $this->createMock(TrackableModel::class);
$this->twig = $this->createMock(Environment::class);
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->leadFieldModel = $this->createMock(FieldModel::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
parent::setUp();
}
#[\PHPUnit\Framework\Attributes\DataProvider('focusTypeProvider')]
public function testGetContentWithForm(string $type, InvokedCount $count): void
{
$this->formModel->expects(self::once())->method('getPages')->willReturn(['', '']);
$this->formModel->expects($count)->method('getEntity');
$focusModel = new FocusModel(
$this->formModel,
$this->trackableModel,
$this->twig,
$this->leadFieldModel,
$this->contactTracker,
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->dispatcher,
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class)
);
$focus = [
'form' => 'xxx',
'type' => $type,
];
$focusModel->getContent($focus);
}
public static function focusTypeProvider(): \Generator
{
yield ['form', self::once()];
yield ['notice', self::never()];
}
}

View File

@@ -0,0 +1,13 @@
--TEST--
compile LESS
--TEMPLATE--
{{ less|less_compile }}
--DATA--
return ['less' => "@primarycolor: #FF7F50;@color:#800080;h2{color: @primarycolor;}h3{color: @color;}"]
--EXPECT--
h2 {
color: #FF7F50;
}
h3 {
color: #800080;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Twig;
use MauticPlugin\MauticFocusBundle\Twig\Extension\FocusBundleExtension;
use Twig\Extension\ExtensionInterface;
/**
* @see https://twig.symfony.com/doc/2.x/advanced.html#functional-tests
*/
class TwigIntegrationTest extends \Twig\Test\IntegrationTestCase
{
/**
* @return ExtensionInterface[]
*/
public function getExtensions(): array
{
return [
new FocusBundleExtension(),
];
}
public static function getFixturesDirectory(): string
{
return __DIR__.'/Fixtures/';
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace MauticPlugin\MauticFocusBundle\Tests\Unit\Helper;
use MauticPlugin\MauticFocusBundle\Helper\IframeAvailabilityChecker;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
class IframeAvailabilityCheckerTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject&TranslatorInterface
*/
private MockObject $translator;
private IframeAvailabilityChecker $helper;
public function setUp(): void
{
$this->translator = $this->createMock(TranslatorInterface::class);
$this->helper = new IframeAvailabilityChecker($this->translator);
}
public function testCheckProtocolMismatch(): void
{
$currentScheme = 'https';
$url = 'http://google.com'; // NOSONAR
$translatedErrorMessage = 'error';
$expectedResponseContent = [
'status' => 0,
'errorMessage' => $translatedErrorMessage,
];
$this->translator->expects($this->once())
->method('trans')
->with(
'mautic.focus.protocol.mismatch',
[
'%url%' => str_replace('http://', 'https://', $url),
]
)
->willReturn($translatedErrorMessage);
/** @var JsonResponse $response */
$response = $this->helper->check($url, $currentScheme);
$responseBody = json_decode($response->getContent(), true);
$this->assertEquals($expectedResponseContent, $responseBody);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Tests\Helper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use MauticPlugin\MauticFocusBundle\Helper\TokenHelper;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\RouterInterface;
class TokenHelperTest extends TestCase
{
/**
* @var FocusModel|MockObject
*/
private MockObject $model;
/**
* @var MockObject|RouterInterface
*/
private MockObject $router;
/**
* @var CorePermissions|MockObject
*/
private MockObject $security;
private TokenHelper $helper;
protected function setUp(): void
{
parent::setUp();
$this->model = $this->createMock(FocusModel::class);
$this->router = $this->createMock(RouterInterface::class);
$this->security = $this->createMock(CorePermissions::class);
$this->helper = new TokenHelper($this->model, $this->router, $this->security);
}
public function testFindFocusTokensNotFound(): void
{
$content = 'content';
self::assertSame([], $this->helper->findFocusTokens($content));
}
public function testFindFocusTokensFound(): void
{
$content = 'content {focus=1}';
self::assertSame(['{focus=1}' => ''], $this->helper->findFocusTokens($content));
}
public function testFindFocusTokensFoundAddScriptByFocusPublishedStatus(): void
{
$focusItemId = 1;
$content = "content {focus=$focusItemId}";
$focusItem = new Focus();
$focusItem->setIsPublished(true);
$this->model->expects(self::once())
->method('getEntity')
->with($focusItemId)
->willReturn($focusItem);
self::assertSame(
['{focus=1}' => '<script src="" type="text/javascript" charset="utf-8" async="async"></script>'],
$this->helper->findFocusTokens($content)
);
}
public function testFindFocusTokensFoundAddScriptByAccessCheck(): void
{
$focusItemId = 1;
$createdById = 2;
$content = "content {focus=$focusItemId}";
$focusItem = new Focus();
$focusItem->setIsPublished(false);
$focusItem->setCreatedBy($createdById);
$this->model->expects(self::once())
->method('getEntity')
->with($focusItemId)
->willReturn($focusItem);
$this->security->expects(self::once())
->method('hasEntityAccess')
->with(
'focus:items:viewown',
'focus:items:viewother',
$focusItem->getCreatedBy()
)
->willReturn(true);
self::assertSame(
['{focus=1}' => '<script src="" type="text/javascript" charset="utf-8" async="async"></script>'],
$this->helper->findFocusTokens($content)
);
}
}

View File

@@ -0,0 +1,2 @@
mautic.focus.error.notfound = "The focus requested was not found."
mautic.focus.notice.batch_deleted = "Focus items successfully deleted."

View File

@@ -0,0 +1,175 @@
mautic.focus.permissions.items="Focus Items - User has access to"
mautic.focus.permissions.header="Focus Permissions"
mautic.focus.form.confirmbatchdelete="Delete the selected focus items?"
mautic.focus="Focus Items"
mautic.focus.edit="Edit %name%"
mautic.focus.fetch_snapshot="Fetch"
mautic.focus.focus_items="Focus Items"
mautic.focus.focus_item="Focus Item"
mautic.focus.unsaved="Unsaved Focus Item"
mautic.focus.builder="Focus Builder"
mautic.focus.details.views="Views"
mautic.focus.details.views.tooltip="Number of times the focus item has been displayed."
mautic.focus.details.unique_views="Unique views"
mautic.focus.details.unique_views.tooltip="Number of unique visitors who have seen the focus item."
mautic.focus.form.activate_for_links="Activate on link clicks?"
mautic.focus.form.animate="Animate?"
mautic.focus.form.bar.allow_hide="Allow hide?"
mautic.focus.form.bar.push_page.tooltip="If set to no, the bar will overlap the page content."
mautic.focus.form.bar.push_page="Push page down?"
mautic.focus.form.bar.size.large="Large; 50px height and 17pt font"
mautic.focus.form.bar.size.regular="Regular; 30px height and 14pt font"
mautic.focus.form.bar.size="Size"
mautic.focus.form.bar.sticky.tooltip="If yes, the bar will always be present while scrolling."
mautic.focus.form.bar.sticky="Make sticky?"
mautic.focus.form.button_color="Button color"
mautic.focus.form.button_text_color="Button text color"
mautic.focus.form.choose_form="Select the form to insert"
mautic.focus.form.engage_after_conversion.tooltip="If set to yes and the visitor submits the form or clicks the link, the focus will not longer be displayed for the until the visitor clears their cookies."
mautic.focus.form.engage_after_conversion="Stop engaging after a conversion?"
mautic.focus.form.stop_after_close.tooltip="If set to Yes and the visitor closes the Focus directly, the Focus will not be displayed again until the visitor clears their cookies."
mautic.focus.form.stop_after_close="Stop engaging after closing the focus?"
mautic.focus.form.font="Font"
mautic.focus.form_token.instructions="Insert <strong>{focus_form}</strong> into the content above to inject the selected form."
mautic.focus.form.frequency.daily="Once per day"
mautic.focus.form.frequency.daily.description="Show the focus once per day"
mautic.focus.form.frequency.everypage="Every page"
mautic.focus.form.frequency.everypage.description="Show the focus on every page"
mautic.focus.form.frequency.hourly="Once per hour"
mautic.focus.form.frequency.hourly.description="Show the focus once per hour"
mautic.focus.form.frequency.once="Once per session"
mautic.focus.form.frequency.once.description="Show the focus once per session"
mautic.focus.form.frequency.q15m="Every 15 minutes"
mautic.focus.form.frequency.q15min.description="Show the focus every 15 minutes"
mautic.focus.form.frequency.q2m="Every 2 minutes"
mautic.focus.form.frequency.q2min.description="Show the focus every 2 minutes"
mautic.focus.form.frequency.help="When selecting 'Once per session', the Focus Item shows once for each active browser session, which ends when a visitor leaves your website."
mautic.focus.form.frequency="How often to engage?"
mautic.focus.form.custom.css="Custom CSS"
mautic.focus.form.custom.css.help="Enter your custom styles without <&#8239;style&#8239;> tags"
mautic.focus.form.headline="Headline"
mautic.focus.form.link_new_window="Open link in a new window?"
mautic.focus.form.link_text="Link text"
mautic.focus.form.link_url="Link URL"
mautic.focus.form.placement.bottom_left="Bottom Left"
mautic.focus.form.placement.bottom_right="Bottom Right"
mautic.focus.form.placement.bottom="Bottom"
mautic.focus.form.placement.middle="Middle"
mautic.focus.form.placement.top_left="Top Left"
mautic.focus.form.placement.top_right="Top Right"
mautic.focus.form.placement.top="Top"
mautic.focus.form.placement.help="Enable sticky to fix the bar at the viewports bottom or disable it to place it at the pages end."
mautic.focus.form.placement="Placement"
mautic.focus.form.primary_color="Primary color"
mautic.focus.form.style="What style should be used?"
mautic.focus.form.tagline="Tagline"
mautic.focus.form.text_color="Text color"
mautic.focus.form.type.form_description="Use a Mautic form to collect data from the visitor."
mautic.focus.form.type.form="Collect data"
mautic.focus.form.type.link_description="Get visitors to click through to a specific URL."
mautic.focus.form.type.link="Emphasize a link"
mautic.focus.form.type.notice_description="Display a notice to your visitors."
mautic.focus.form.type.notice="Display a notice"
mautic.focus.form.type.tooltip="Select what type of focus is desired."
mautic.focus.form.type="What should the focus be?"
mautic.focus.form.html_mode.tooltip="HTML mode allows adding your custom HTML code to Focus."
mautic.focus.form.html_mode="Content mode"
mautic.focus.form.basic="Basic"
mautic.focus.form.editor="Editor"
mautic.focus.form.html="HTML"
mautic.focus.form.website.tooltip="Enter your website for Mautic to grab a screenshot and display an example."
mautic.focus.form.website="Website"
mautic.focus.form.when.5seconds="5 second delay"
mautic.focus.form.when.5seconds.description="Show the focus after a 5 second delay"
mautic.focus.form.when.immediately="Upon arrival"
mautic.focus.form.when.immediately.description="Show the focus immediately upon arrival"
mautic.focus.form.when.leave="Visitor intends to leave"
mautic.focus.form.when.leave.description="Show the focus when the visitor intends to leave"
mautic.focus.form.when.minute="1 minute delay"
mautic.focus.form.when.minute.description="Show the focus after a 1 minute delay."
mautic.focus.form.when.scroll_bottom="After scrolling to bottom"
mautic.focus.form.when.scroll_bottom.description="Show the focus after scrolling to the bottom."
mautic.focus.form.when.scroll_middle="After scrolling to middle"
mautic.focus.form.when.scroll_middle.description="Show the focus after the user has scrolled to the middle of the page."
mautic.focus.form.when.scroll_slight="After slightly scrolling down"
mautic.focus.form.when.scroll_slight.description="Show the focus after the user scrolls down slightly on the page."
mautic.focus.form.timeout="Timeout before engage"
mautic.focus.form.timeout.description="Timeout before engage (in seconds)"
mautic.focus.form.timeout.seconds="sec"
mautic.focus.form.when="When to engage?"
mautic.focus.graph.clicks="Clicks"
mautic.focus.graph.stats="Focus Stats"
mautic.focus.graph.submissions="Submissions"
mautic.focus.graph.views="Views"
mautic.focus.install.description="Copy this line of code into your site, preferably right before the closing body tag. Alternatively, show this focus item through a campaign action in response to a page view on a page that has the Mautic tracking script."
mautic.focus.install.header="Focus Installation"
mautic.focus.new="New Focus"
mautic.focus.noresults.tip="Get the attention of visitors on your website with Focus Forms, Focus Clicks, or Focus Notifications."
mautic.focus.style="Style"
mautic.focus.style.bar_description="Display a bar across the top of the page."
mautic.focus.style.bar="Bar"
mautic.focus.style.modal_description="Display a popup in the middle of the page."
mautic.focus.style.modal="Modal"
mautic.focus.style.notification_description="Display a small window in a corner of the page."
mautic.focus.style.notification="Notification"
mautic.focus.style.page_description="Display a window that covers the entire page."
mautic.focus.style.page="Full Page"
mautic.focus.tab.focus_colors="Colors"
mautic.focus.tab.focus_content="Content"
mautic.focus.tab.focus_style="Style"
mautic.focus.tab.focus_type="Focus"
mautic.focus.thead.style="Focus Style"
mautic.focus.onboarding.eyebrow="Functionality"
mautic.focus.onboarding.purpose.heading="Core objectives"
mautic.focus.onboarding.purpose.copy="Collect subscriber information, highlight important links, or display crucial notices, helping you generate contacts and guide their journey."
mautic.focus.onboarding.style.eyebrow="Style"
mautic.focus.onboarding.style.bar.heading="Bar"
mautic.focus.onboarding.style.bar.copy="Thin banner (top/bottom) for less intrusive notices or simple CTAs."
mautic.focus.onboarding.style.modal.heading="Modal"
mautic.focus.onboarding.style.modal.copy="Overlay window, often triggered, for lead capture or special offers."
mautic.focus.onboarding.style.notification.heading="Notification"
mautic.focus.onboarding.style.notification.copy="Small slide-in box (corner) for brief messages or subtle alerts."
mautic.focus.onboarding.style.fullpage.heading="Full Page"
mautic.focus.onboarding.style.fullpage.copy="Covers the entire screen for critical messages, gates, or urgent CTAs."
mautic.focus.onboarding.styles.heading="Styles"
mautic.focus.onboarding.heading="Engage your website visitors"
mautic.focus.onboarding.subheading="These eye-catching bars, pop-ups (modals), notifications, or full-page displays grab attention at key moments."
mautic.focus.thead.type="Focus Type"
mautic.focus.type="Type"
mautic.focus.type.form="Form"
mautic.focus.type.link="Link"
mautic.focus.type.notice="Notice"
mautic.focus.website_placeholder="Enter a website below and I'll grab a snapshot so you can see an example of what your Focus will look like on your site as you build it!"
mautic.focums.html_mode.tooltip="Pick one mode. Content will not carry over when switching between modes"
mautic.focus.campaign.event.show_focus="Show Focus Item"
mautic.focus.campaign.event.show_focus_descr="Trigger Focus Item"
mautic.focus.no.focusitem.note="There are no Focus Item to choose from. Create some first."
mautic.focus.focusitem.selectitem="Select Focus Item"
mautic.focus.focusitem.selectitem_descr="Choose the Focus Item"
mautic.focus.show.new.item="New Focus Item"
mautic.focus.show.edit.item="Edit Focus Item"
mautic.focus.preview="Preview"
mautic.focus.show.preview.item="Preview Focus Item"
mautic.campaign.focus.show="Focus Item"
mautic.focus.form.confirmdelete="Delete the Focus item, %name%?"
mautic.focus.protocol.mismatch="This site cannot be previewed because it is not secure. Please try using %url%."
mautic.focus.blocking.iframe.header="This website blocks iframe previews with the %header% header. Please try another URL."
mautic.focus.event.view="Focus view"
mautic.focus.view="Focus view"
mautic.focus.event.click="Focus click"
mautic.focus.click="Focus click"
mautic.focus.header="Focus Item"
mautic.focus.interaction="Interaction"
mautic.report.group.focus="Focus Items"
mautic.report.focus.id="Focus ID"
mautic.report.focus.name="Focus Name"
mautic.report.focus.description="Focus Description"
mautic.report.focus.category="Focus Category"
mautic.report.focus.style="Focus Style"
mautic.report.focus.hits="Hits"
mautic.report.focus.uniquehits="Unique Hits"
mautic.report.focus.ratio.click="Click ratio"
mautic.report.focus.ratio.submission="Submission ratio"
mautic.report.datasource.focus.leads="Focus Leads"
mautic.report.source.focus_leads="Focus Leads"
mautic.focus.help.searchcommands="<strong>Search commands</strong><br />ids:ID1,ID2 (comma separated IDs, no spaces)<br />is:published<br />is:unpublished<br />is:mine<br />is:uncategorized<br />category:{category alias}<br />project:\"Project Name\""

View File

@@ -0,0 +1,3 @@
mautic.focus.error.select_type="Use the builder to select what the focus should be."
mautic.focus.error.select_style="Use the builder to select what style should be used."
mautic.focus.choosefocus.notblank="A focus item is required."

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticFocusBundle\Twig\Extension;
use MatthiasMullie\Minify;
use MauticPlugin\MauticFocusBundle\Model\FocusModel;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigTest;
class FocusBundleExtension extends AbstractExtension
{
/**
* @return TwigFilter[]
*/
public function getFilters(): array
{
return [
new TwigFilter('less_compile', [$this, 'compileLess'], ['is_safe' => ['all']]),
new TwigFilter('css_minify', [$this, 'minifyCss'], ['is_safe' => ['all']]),
];
}
/**
* @return TwigTest[]
*/
public function getTests(): array
{
return [
new TwigTest('color light', fn (string $hexColor) => FocusModel::isLightColor($hexColor)),
];
}
public function compileLess(string $less): string
{
$parser = new \Less_Parser();
return $parser->parse($less)->getCss();
}
public function minifyCss(string $css): string
{
return (new Minify\CSS($css))->minify();
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "mautic/plugin-focus",
"description": "Focus Plugin",
"type": "mautic-plugin",
"keywords": [
"mautic",
"plugin",
"integration"
],
"extra": {
"install-directory-name": "MauticFocusBundle"
},
"minimum-stability": "dev",
"require": {
"mautic/core-lib": "^7.0"
}
}