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 @@
{"hit":{"dateHit":"2015-08-26T01:32:39+00:00","dateLeft":null,"page":{"id":1,"title":"PageHit","alias":"pagehit","category":null},"redirect":null,"email":null,"lead":{"id":26,"points":10,"color":null,"fields":{"core":{"title":{"id":"1","label":"Title","alias":"title","type":"lookup","group":"core","value":null},"firstname":{"id":"2","label":"First Name","alias":"firstname","type":"text","group":"core","value":null},"lastname":{"id":"3","label":"Last Name","alias":"lastname","type":"text","group":"core","value":null},"company":{"id":"4","label":"Company","alias":"company","type":"lookup","group":"core","value":null},"position":{"id":"5","label":"Position","alias":"position","type":"text","group":"core","value":null},"email":{"id":"6","label":"Email","alias":"email","type":"email","group":"core","value":"email@formsubmit.com"},"phone":{"id":"7","label":"Phone","alias":"phone","type":"tel","group":"core","value":null},"mobile":{"id":"8","label":"Mobile","alias":"mobile","type":"tel","group":"core","value":null},"fax":{"id":"9","label":"Fax","alias":"fax","type":"text","group":"core","value":null},"address1":{"id":"10","label":"Address Line 1","alias":"address1","type":"text","group":"core","value":null},"address2":{"id":"11","label":"Address Line 2","alias":"address2","type":"text","group":"core","value":null},"city":{"id":"12","label":"City","alias":"city","type":"lookup","group":"core","value":null},"state":{"id":"13","label":"State","alias":"state","type":"region","group":"core","value":null},"zipcode":{"id":"14","label":"Zipcode","alias":"zipcode","type":"lookup","group":"core","value":null},"country":{"id":"15","label":"Country","alias":"country","type":"country","group":"core","value":null},"website":{"id":"16","label":"Website","alias":"website","type":"text","group":"core","value":null}},"social":{"twitter":{"id":"17","label":"Twitter","alias":"twitter","type":"text","group":"social","value":null},"facebook":{"id":"18","label":"Facebook","alias":"facebook","type":"text","group":"social","value":null},"skype":{"id":"20","label":"Skype","alias":"skype","type":"text","group":"social","value":null},"instagram":{"id":"21","label":"Instagram","alias":"instagram","type":"text","group":"social","value":null},"foursquare":{"id":"22","label":"Foursquare","alias":"foursquare","type":"text","group":"social","value":null}},"personal":[],"professional":[]}},"ipAddress":{},"country":null,"region":null,"city":null,"isp":null,"organization":null,"code":200,"referer":null,"url":"http:\/\/mautic-gh.com\/pagehit","urlTitle":null,"userAgent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/44.0.2403.157 Safari\/537.36","remoteHost":"localhost","pageLanguage":"en","browserLanguages":["en-US","en;q=0.8"],"trackingId":"833fecc93e16d37baf1530df643b6a8b10714c65","source":null,"sourceId":null}}

View File

@@ -0,0 +1,9 @@
/* PageBundle */
.col-page-id {
width: 75px;
}
.page-builder .builder-panel .panel-body {
padding: 5px 0;
}

View File

@@ -0,0 +1,64 @@
//PageBundle
Mautic.pageOnLoad = function (container, response) {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'page.page');
}
if (mQuery(container + ' #page_template').length) {
Mautic.toggleBuilderButton(mQuery('#page_template').val() == '');
// Preload tokens for code mode builder
Mautic.getTokens(Mautic.getBuilderTokensMethod(), function(){});
Mautic.initSelectTheme(mQuery('#page_template'));
}
// Open the builder directly when saved from the builder
if (response && response.inBuilder) {
Mautic.launchBuilder('page');
Mautic.processBuilderErrors(response);
}
};
Mautic.getPageAbTestWinnerForm = function(abKey) {
if (abKey && mQuery(abKey).val() && mQuery(abKey).closest('.form-group').hasClass('has-error')) {
mQuery(abKey).closest('.form-group').removeClass('has-error');
if (mQuery(abKey).next().hasClass('help-block')) {
mQuery(abKey).next().remove();
}
}
Mautic.activateLabelLoadingIndicator('page_variantSettings_winnerCriteria');
var pageId = mQuery('#page_sessionId').val();
var query = "action=page:getAbTestForm&abKey=" + mQuery(abKey).val() + "&pageId=" + pageId;
mQuery.ajax({
url: mauticAjaxUrl,
type: "POST",
data: query,
dataType: "json",
success: function (response) {
if (typeof response.html != 'undefined') {
if (mQuery('#page_variantSettings_properties').length) {
mQuery('#page_variantSettings_properties').replaceWith(response.html);
} else {
mQuery('#page_variantSettings').append(response.html);
}
if (response.html != '') {
Mautic.onPageLoad('#page_variantSettings_properties', response);
}
}
Mautic.removeLabelLoadingIndicator();
},
error: function (request, textStatus, errorThrown) {
Mautic.processAjaxError(request, textStatus, errorThrown);
spinner.remove();
},
complete: function () {
Mautic.removeLabelLoadingIndicator();
}
});
};

View File

@@ -0,0 +1,40 @@
/** This section is only needed once per page if manually copying **/
if (typeof MauticPrefCenterLoaded === 'undefined') {
var MauticPrefCenterLoaded = true;
function togglePreferredChannel(channel) {
var status = document.getElementById(channel).checked;
const fieldsToToggle = [
'frequency_number',
'frequency_time',
'contact_pause_start_date',
'contact_pause_end_date',
// Do we need the 4 above?
'lead_channels_frequency_number',
'lead_channels_frequency_time',
'lead_channels_contact_pause_start_date',
'lead_channels_contact_pause_end_date',
];
fieldsToToggle.forEach(field => {
const element = document.getElementById('lead_contact_frequency_rules_' + field + '_' + channel);
if (element) {
if (status) {
element.removeAttribute('disabled');
} else {
element.setAttribute('disabled', 'disabled');
}
element.dispatchEvent(new CustomEvent('chosen:updated'));
}
});
}
function saveUnsubscribePreferences(formId) {
var forms = document.getElementsByName(formId);
for (var i = 0; i < forms.length; i++) {
if (forms[i].tagName === 'FORM') {
forms[i].submit();
}
}
}
}

View File

@@ -0,0 +1,140 @@
<?php
return [
'routes' => [
'main' => [
'mautic_page_index' => [
'path' => '/pages/{page}',
'controller' => 'Mautic\PageBundle\Controller\PageController::indexAction',
],
'mautic_page_action' => [
'path' => '/pages/{objectAction}/{objectId}',
'controller' => 'Mautic\PageBundle\Controller\PageController::executeAction',
],
'mautic_page_results' => [
'path' => '/pages/results/{objectId}/{page}',
'controller' => 'Mautic\PageBundle\Controller\PageController::resultsAction',
],
'mautic_page_export' => [
'path' => '/pages/results/{objectId}/export/{format}',
'controller' => 'Mautic\PageBundle\Controller\PageController::exportAction',
'defaults' => [
'format' => 'csv',
],
],
],
'public' => [
'mautic_page_tracker' => [
'path' => '/mtracking.gif',
'controller' => 'Mautic\PageBundle\Controller\PublicController::trackingImageAction',
],
'mautic_page_tracker_cors' => [
'path' => '/mtc/event',
'controller' => 'Mautic\PageBundle\Controller\PublicController::trackingAction',
],
'mautic_page_tracker_getcontact' => [
'path' => '/mtc',
'controller' => 'Mautic\PageBundle\Controller\PublicController::getContactIdAction',
],
'mautic_url_redirect' => [
'path' => '/r/{redirectId}',
'controller' => 'Mautic\PageBundle\Controller\PublicController::redirectAction',
],
'mautic_page_redirect' => [
'path' => '/redirect/{redirectId}',
'controller' => 'Mautic\PageBundle\Controller\PublicController::redirectAction',
],
'mautic_page_preview' => [
'path' => '/page/preview/{id}/{objectType}',
'controller' => 'Mautic\PageBundle\Controller\PublicController::previewAction',
'defaults' => ['objectType' => null],
],
],
'api' => [
'mautic_api_pagesstandard' => [
'standard_entity' => true,
'name' => 'pages',
'path' => '/pages',
'controller' => Mautic\PageBundle\Controller\Api\PageApiController::class,
],
],
'catchall' => [
'mautic_page_public' => [
'path' => '/{slug}',
'controller' => 'Mautic\PageBundle\Controller\PublicController::indexAction',
'requirements' => [
'slug' => '^(?!(_(profiler|wdt)|css|images|js|favicon.ico|apps/bundles/|plugins/)).+',
],
],
],
],
'menu' => [
'main' => [
'items' => [
'mautic.page.pages' => [
'route' => 'mautic_page_index',
'access' => ['page:pages:viewown', 'page:pages:viewother'],
'parent' => 'mautic.core.components',
'priority' => 100,
],
],
],
],
'categories' => [
'page' => [
'class' => Mautic\PageBundle\Entity\Page::class,
],
],
'services' => [
'fixtures' => [
'mautic.page.fixture.page_category' => [
'class' => Mautic\PageBundle\DataFixtures\ORM\LoadPageCategoryData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['mautic.category.model.category'],
],
'mautic.page.fixture.page' => [
'class' => Mautic\PageBundle\DataFixtures\ORM\LoadPageData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['mautic.page.model.page'],
],
'mautic.page.fixture.page_hit' => [
'class' => Mautic\PageBundle\DataFixtures\ORM\LoadPageHitData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['mautic.page.model.page'],
],
],
'other' => [
'mautic.page.helper.token' => [
'class' => Mautic\PageBundle\Helper\TokenHelper::class,
'arguments' => 'mautic.page.model.page',
],
'mautic.page.helper.tracking' => [
'class' => Mautic\PageBundle\Helper\TrackingHelper::class,
'arguments' => [
'mautic.tracker.contact',
'mautic.cache.provider',
'mautic.helper.core_parameters',
'request_stack',
],
],
],
],
'parameters' => [
'cat_in_page_url' => false,
'google_analytics' => null,
'track_contact_by_ip' => false,
'track_by_fingerprint' => false,
'google_analytics_id' => null,
'google_analytics_trackingpage_enabled' => false,
'google_analytics_landingpage_enabled' => false,
'google_analytics_anonymize_ip' => false,
'facebook_pixel_id' => null,
'facebook_pixel_trackingpage_enabled' => false,
'facebook_pixel_landingpage_enabled' => false,
'do_not_track_404_anonymous' => false,
],
];

View File

@@ -0,0 +1,33 @@
<?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('Mautic\\PageBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\PageBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->get(Mautic\PageBundle\Model\PageModel::class)->call('setCatInUrl', ['%mautic.cat_in_page_url%']);
$services->alias('mautic.page.model.page', Mautic\PageBundle\Model\PageModel::class);
$services->alias('mautic.page.model.redirect', Mautic\PageBundle\Model\RedirectModel::class);
$services->alias('mautic.page.model.trackable', Mautic\PageBundle\Model\TrackableModel::class);
$services->alias('mautic.page.model.video', Mautic\PageBundle\Model\VideoModel::class);
$services->alias('mautic.page.model.tracking.404', Mautic\PageBundle\Model\Tracking404Model::class);
$services->alias('mautic.page.repository.hit', Mautic\PageBundle\Entity\HitRepository::class);
$services->alias('mautic.page.repository.page', Mautic\PageBundle\Entity\PageRepository::class);
$services->alias('mautic.page.repository.redirect', Mautic\PageBundle\Entity\RedirectRepository::class);
};

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\PageBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Controller\VariantAjaxControllerTrait;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\PageBundle\Form\Type\AbTestPropertiesType;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
class AjaxController extends CommonAjaxController
{
use VariantAjaxControllerTrait;
public function getAbTestFormAction(Request $request, FormFactoryInterface $formFactory, PageModel $pageModel, Environment $twig): JsonResponse
{
return $this->sendJsonResponse($this->getAbTestForm(
$request,
$pageModel,
fn ($formType, $formOptions) => $formFactory->create(AbTestPropertiesType::class, [], ['formType' => $formType, 'formTypeOptions' => $formOptions]),
fn ($form) => $this->renderView('@MauticPage/AbTest/form.html.twig', ['form' => $this->setFormTheme($form, $twig, ['@MauticPage/AbTest/form.html.twig', 'MauticPageBundle:FormTheme\Page'])]),
'page_abtest_settings',
'page'
));
}
public function pageListAction(Request $request): JsonResponse
{
$filter = InputHelper::clean($request->query->get('filter'));
$pageModel = $this->getModel('page.page');
\assert($pageModel instanceof PageModel);
$results = $pageModel->getLookupResults('page', $filter);
$dataArray = [];
foreach ($results as $r) {
$dataArray[] = [
'label' => $r['title']." ({$r['id']}:{$r['alias']})",
'value' => $r['id'],
];
}
return $this->sendJsonResponse($dataArray);
}
/**
* Called by parent::getBuilderTokensAction().
*
* @return array
*/
protected function getBuilderTokens($query)
{
/** @var PageModel $model */
$model = $this->getModel('page');
return $model->getBuilderComponents(null, ['tokens'], $query ?? '');
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Mautic\PageBundle\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\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Page>
*/
class PageApiController extends CommonApiController
{
/**
* @var PageModel|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)
{
$pageModel = $modelFactory->getModel('page');
\assert($pageModel instanceof PageModel);
$this->model = $pageModel;
$this->entityClass = Page::class;
$this->entityNameOne = 'page';
$this->entityNameMulti = 'pages';
$this->serializerGroups = ['pageDetails', 'categoryList', 'publishDetails'];
$this->dataInputMasks = ['customHtml' => 'html'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Obtains a list of pages.
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function getEntitiesAction(Request $request, UserHelper $userHelper)
{
// get parent level only
$this->listFilters[] = [
'column' => 'p.variantParent',
'expr' => 'isNull',
];
$this->listFilters[] = [
'column' => 'p.translationParent',
'expr' => 'isNull',
];
return parent::getEntitiesAction($request, $userHelper);
}
}

View File

@@ -0,0 +1,626 @@
<?php
namespace Mautic\PageBundle\Controller;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Exception\FileNotFoundException;
use Mautic\CoreBundle\Exception\InvalidDecodedStringException;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\ThemeHelper;
use Mautic\CoreBundle\Helper\TrackingPixelHelper;
use Mautic\CoreBundle\Helper\UrlHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Helper\PrimaryCompanyHelper;
use Mautic\LeadBundle\Helper\TokenHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\Event\TrackingEvent;
use Mautic\PageBundle\Helper\PageConfig;
use Mautic\PageBundle\Helper\TrackingHelper;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\Tracking404Model;
use Mautic\PageBundle\Model\VideoModel;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
class PublicController extends AbstractFormController
{
/**
* @param string $slug
*
* @return Response
*
* @throws \Exception
* @throws FileNotFoundException
*/
public function indexAction(
Request $request,
ContactRequestHelper $contactRequestHelper,
CookieHelper $cookieHelper,
AnalyticsHelper $analyticsHelper,
AssetsHelper $assetsHelper,
ThemeHelper $themeHelper,
Tracking404Model $tracking404Model,
RouterInterface $router,
DeviceTrackingServiceInterface $deviceTrackingService,
$slug)
{
/** @var PageModel $model */
$model = $this->getModel('page');
$security = $this->security;
/** @var Page|bool $entity */
$entity = $model->getEntityBySlugs($slug);
// Do not hit preference center pages
if (!empty($entity) && !$entity->getIsPreferenceCenter()) {
$userAccess = $security->hasEntityAccess('page:pages:viewown', 'page:pages:viewother', $entity->getCreatedBy());
$published = $entity->isPublished();
// Make sure the page is published or deny access if not
if (!$published && !$userAccess) {
// If the page has a redirect type, handle it
if (null != $entity->getRedirectType()) {
$model->hitPage($entity, $request, $entity->getRedirectType());
if ($entity->getRedirectUrl()) {
return $this->redirect($entity->getRedirectUrl(), (int) $entity->getRedirectType());
} else {
return $this->notFound();
}
} else {
$model->hitPage($entity, $request, 401);
return $this->accessDenied();
}
}
$lead = null;
$query = null;
if (!$userAccess) {
// Extract the lead from the request so it can be used to determine language if applicable
$query = $model->getHitQuery($request, $entity);
$lead = $contactRequestHelper->getContactFromQuery($query);
}
// Correct the URL if it doesn't match up
if (!$request->attributes->get('ignore_mismatch', 0)) {
// Make sure URLs match up
$url = $model->generateUrl($entity, false);
$requestUri = $request->getRequestUri();
// Remove query when comparing
$query = $request->getQueryString();
if (!empty($query)) {
$requestUri = str_replace("?{$query}", '', $url);
}
// Redirect if they don't match
if ($requestUri != $url) {
$model->hitPage($entity, $request, 301, $lead, $query);
return $this->redirect($url, 301);
}
}
// Check for variants
[$parentVariant, $childrenVariants] = $entity->getVariants();
// Is this a variant of another? If so, the parent URL should be used unless a user is logged in and previewing
if ($parentVariant != $entity && !$userAccess) {
$model->hitPage($entity, $request, 301, $lead, $query);
$url = $model->generateUrl($parentVariant, false);
return $this->redirect($url, 301);
}
// First determine the A/B test to display if applicable
if (!$userAccess) {
// Check to see if a variant should be shown versus the parent but ignore if a user is previewing
if (count($childrenVariants)) {
$variants = [];
$variantWeight = 0;
$totalHits = $entity->getVariantHits();
foreach ($childrenVariants as $id => $child) {
if ($child->isPublished()) {
$variantSettings = $child->getVariantSettings();
$variants[$id] = [
'weight' => ($variantSettings['weight'] / 100),
'hits' => $child->getVariantHits(),
];
$variantWeight += $variantSettings['weight'];
// Count translations for this variant as well
$translations = $child->getTranslations(true);
/** @var Page $translation */
foreach ($translations as $translation) {
if ($translation->isPublished()) {
$variants[$id]['hits'] += (int) $translation->getVariantHits();
}
}
$totalHits += $variants[$id]['hits'];
}
}
if (count($variants)) {
// check to see if this user has already been displayed a specific variant
$variantCookie = $request->cookies->get('mautic_page_'.$entity->getId());
if (!empty($variantCookie)) {
if (isset($variants[$variantCookie])) {
// if not the parent, show the specific variant already displayed to the visitor
if ((string) $variantCookie !== (string) $entity->getId()) {
$entity = $childrenVariants[$variantCookie];
} // otherwise proceed with displaying parent
}
} else {
// Add parent weight
$variants[$entity->getId()] = [
'weight' => ((100 - $variantWeight) / 100),
'hits' => $entity->getVariantHits(),
];
// Count translations for the parent as well
$translations = $entity->getTranslations(true);
/** @var Page $translation */
foreach ($translations as $translation) {
if ($translation->isPublished()) {
$variants[$entity->getId()]['hits'] += (int) $translation->getVariantHits();
}
}
$totalHits += $variants[$id]['hits'];
// determine variant to show
foreach ($variants as &$variant) {
$variant['weight_deficit'] = ($totalHits) ? $variant['weight'] - ($variant['hits'] / $totalHits) : $variant['weight'];
}
// Reorder according to send_weight so that campaigns which currently send one at a time alternate
uasort(
$variants,
function ($a, $b): int {
if ($a['weight_deficit'] === $b['weight_deficit']) {
if ($a['hits'] === $b['hits']) {
return 0;
}
// if weight is the same - sort by least number displayed
return ($a['hits'] < $b['hits']) ? -1 : 1;
}
// sort by the one with the greatest deficit first
return ($a['weight_deficit'] > $b['weight_deficit']) ? -1 : 1;
}
);
// find the one with the most difference from weight
$useId = array_key_first($variants);
// set the cookie - 14 days
$cookieHelper->setCookie(
'mautic_page_'.$entity->getId(),
$useId,
3600 * 24 * 14
);
if ($useId != $entity->getId()) {
$entity = $childrenVariants[$useId];
}
}
}
}
// Now show the translation for the page or a/b test - only fetch a translation if a slug was not used
if ($entity->isTranslation() && empty($entity->languageSlug)) {
[$translationParent, $translatedEntity] = $model->getTranslatedEntity(
$entity,
$lead,
$request
);
\assert($translatedEntity instanceof Page);
if ($translationParent && $translatedEntity !== $entity) {
if (!$request->get('ntrd', 0)) {
$url = $model->generateUrl($translatedEntity, false);
$model->hitPage($entity, $request, 302, $lead, $query);
return $this->redirect($url, 302);
}
}
}
}
// Generate contents
$analytics = $analyticsHelper->getCode();
$BCcontent = $entity->getContent();
$content = $entity->getCustomHtml();
// This condition remains so the Mautic v1 themes would display the content
if (empty($content) && !empty($BCcontent)) {
/**
* @deprecated BC support to be removed in 3.0
*/
$template = $entity->getTemplate();
// all the checks pass so display the content
$content = $entity->getContent();
// Add the GA code to the template assets
if (!empty($analytics)) {
$assetsHelper->addCustomDeclaration($analytics);
}
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');
$response = $this->render(
$logicalName,
[
'content' => $content,
'page' => $entity,
'template' => $template,
'public' => true,
]
);
$content = $response->getContent();
} else {
if (!empty($analytics)) {
$content = str_replace('</head>', $analytics."\n</head>", $content);
}
if ($entity->getNoIndex()) {
$content = str_replace('</head>', "<meta name=\"robots\" content=\"noindex\">\n</head>", $content);
}
}
$assetsHelper->addScript(
$router->generate('mautic_js', [], UrlGeneratorInterface::ABSOLUTE_URL),
'onPageDisplay_headClose',
true,
'mautic_js'
);
$event = new PageDisplayEvent((string) $content, $entity);
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
$content = $event->getContent();
$model->hitPage($entity, $request, Response::HTTP_OK, $lead, $query);
$response = new Response($content);
if ($request->cookies->has('Blocked-Tracking')) {
$deviceTrackingService->clearTrackingCookies();
}
return $response;
}
if (false !== $entity && $tracking404Model->isTrackable()) {
$tracking404Model->hitPage($entity, $request);
}
return $this->notFound();
}
/**
* @return mixed[]|JsonResponse|RedirectResponse|Response
*
* @throws FileNotFoundException
*/
public function previewAction(Request $request, PageConfig $pageConfig, CorePermissions $security, AnalyticsHelper $analyticsHelper, AssetsHelper $assetsHelper, ThemeHelper $themeHelper, int $id, ?string $objectType = null)
{
/** @var PageModel $model */
$model = $this->getModel('page');
/** @var Page $page */
$page = $model->getEntity($id);
if (!$page || !$page->getId()) {
return $this->notFound();
}
$contactId = (int) $request->query->get('contactId');
if ($contactId) {
/** @var LeadModel $leadModel */
$leadModel = $this->getModel('lead.lead');
$contact = $leadModel->getEntity($contactId);
}
$draftEnabled = $pageConfig->isDraftEnabled();
$analytics = $analyticsHelper->getCode();
$BCcontent = $page->getContent();
$content = $page->getCustomHtml();
$publicPreview = $page->isPublicPreview();
if ('draft' === $objectType && $draftEnabled && $page->hasDraft()) {
$content = $page->getDraftContent();
$publicPreview = $page->getDraft()->isPublicPreview();
}
if (($security->isAnonymous() && (!$page->isPublished() || !$publicPreview)) || (!$security->isAnonymous(
) && !$security->hasEntityAccess(
'page:pages:viewown',
'page:pages:viewother',
$page->getCreatedBy()
))) {
return $this->accessDenied();
}
if ($contactId && (!$security->isAdmin() || !$security->hasEntityAccess(
'lead:leads:viewown',
'lead:leads:viewother'
))) {
// disallow displaying contact information
return $this->accessDenied();
}
if (empty($content) && !empty($BCcontent)) {
$template = $page->getTemplate();
// all the checks pass so display the content
$content = $page->getContent();
// Add the GA code to the template assets
if (!empty($analytics)) {
$assetsHelper->addCustomDeclaration($analytics);
}
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');
$response = $this->render(
$logicalName,
[
'content' => $content,
'page' => $page,
'template' => $template,
'public' => true, // @deprecated Remove in 2.0
]
);
$content = $response->getContent();
} else {
$content = str_replace('</head>', $analytics."\n</head>", $content);
}
if ($this->dispatcher->hasListeners(PageEvents::PAGE_ON_DISPLAY)) {
$event = new PageDisplayEvent($content, $page, $this->getPreferenceCenterConfig());
if (isset($contact) && $contact instanceof Lead) {
$event->setLead($contact);
}
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
$content = $event->getContent();
}
return new Response($content);
}
/**
* @return Response
*
* @throws \Exception
*/
public function trackingImageAction(Request $request)
{
/** @var PageModel $model */
$model = $this->getModel('page');
$model->hitPage(null, $request);
return TrackingPixelHelper::getResponse($request);
}
/**
* @return JsonResponse
*
* @throws \Exception
*/
public function trackingAction(
Request $request,
DeviceTrackingServiceInterface $deviceTrackingService,
TrackingHelper $trackingHelper,
ContactTracker $contactTracker,
) {
$notSuccessResponse = new JsonResponse(
[
'success' => 0,
]
);
if (!$this->security->isAnonymous()) {
return $notSuccessResponse;
}
/** @var PageModel $model */
$model = $this->getModel('page');
try {
$model->hitPage(null, $request);
} catch (InvalidDecodedStringException) {
// do not track invalid ct
return $notSuccessResponse;
}
$lead = $contactTracker->getContact();
$trackedDevice = $deviceTrackingService->getTrackedDevice();
$trackingId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());
$sessionValue = $trackingHelper->getCacheItem(true);
$event = new TrackingEvent($lead, $request, $sessionValue);
$this->dispatcher->dispatch($event, PageEvents::ON_CONTACT_TRACKED);
return new JsonResponse(
[
'success' => 1,
'id' => ($lead) ? $lead->getId() : null,
'sid' => $trackingId,
'device_id' => $trackingId,
'events' => $event->getResponse()->all(),
]
);
}
/**
* @throws \Exception
*/
public function redirectAction(
Request $request,
ContactRequestHelper $contactRequestHelper,
PrimaryCompanyHelper $primaryCompanyHelper,
IpLookupHelper $ipLookupHelper,
LoggerInterface $logger,
$redirectId,
): RedirectResponse {
$logger->debug('Attempting to load redirect with tracking_id of: '.$redirectId);
/** @var RedirectModel $redirectModel */
$redirectModel = $this->getModel(RedirectModel::class);
$redirect = $redirectModel->getRedirectById($redirectId);
$logger->debug('Executing Redirect: '.$redirect);
if (null === $redirect || !$redirect->isPublished(false)) {
$logger->debug('Redirect with tracking_id of '.$redirectId.' not found');
$url = ($redirect) ? $redirect->getUrl() : 'n/a';
throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url]));
}
// Ensure the URL does not have encoded ampersands
$url = UrlHelper::decodeAmpersands($redirect->getUrl());
// Get query string
$query = $request->query->all();
$ct = null;
// Remove click-trough parameter, so it won't be duplicated later.
if (isset($query['ct'])) {
$ct = $query['ct'];
unset($query['ct']);
}
// Tak on anything left to the URL
if (count($query)) {
$url = UrlHelper::appendQueryToUrl($url, http_build_query($query));
}
// If the IP address is not trackable, it means it came form a configured "do not track" IP or a "do not track" user agent
// This prevents simulated clicks from 3rd party services such as URL shorteners from simulating clicks
$ipAddress = $ipLookupHelper->getIpAddress();
$isHitTrackable = false;
if (null !== $ct && '' !== $ct) {
if ($ipAddress->isTrackable()) {
// Search replace lead fields in the URL
/** @var PageModel $pageModel */
$pageModel = $this->getModel(PageModel::class);
try {
$lead = $contactRequestHelper->getContactFromQuery(['ct' => $ct]);
$isHitTrackable = $pageModel->hitPage($redirect, $request, 200, $lead);
} catch (InvalidDecodedStringException $e) {
// Invalid ct value so we must unset it
// and process the request without it
$logger->error(sprintf('Invalid clickthrough value: %s', $ct), ['exception' => $e]);
$request->request->remove('ct');
$request->query->remove('ct');
$lead = $contactRequestHelper->getContactFromQuery();
$isHitTrackable = $pageModel->hitPage($redirect, $request, 200, $lead);
}
$leadArray = ($lead) ? $primaryCompanyHelper->getProfileFieldsWithPrimaryCompany($lead) : [];
$url = TokenHelper::findLeadTokens($url, $leadArray, true);
}
if (str_contains($url, $this->generateUrl('mautic_asset_download'))) {
if (str_contains($url, '?')) {
$url .= '&ct='.$ct;
} else {
$url .= '?ct='.$ct;
}
}
}
$url = UrlHelper::sanitizeAbsoluteUrl($url);
if (!UrlHelper::isValidUrl($url)) {
throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url]));
}
$response = $this->redirect($url);
$response->headers->setCookie(new Cookie('Blocked-Tracking', (string) !$isHitTrackable, strtotime('now + 15 seconds')));
return $response;
}
/**
* Track video views.
*/
public function hitVideoAction(Request $request): JsonResponse|Response
{
// Only track XMLHttpRequests, because the hit should only come from there
if ($request->isXmlHttpRequest()) {
/** @var VideoModel $model */
$model = $this->getModel('page.video');
try {
$model->hitVideo($request);
} catch (\Exception) {
return new JsonResponse(['success' => false]);
}
return new JsonResponse(['success' => true]);
}
return new Response();
}
/**
* Get the ID of the currently tracked Contact.
*/
public function getContactIdAction(DeviceTrackingServiceInterface $trackedDeviceService, ContactTracker $contactTracker): JsonResponse
{
$data = [];
if ($this->security->isAnonymous()) {
$lead = $contactTracker->getContact();
$trackedDevice = $trackedDeviceService->getTrackedDevice();
$trackingId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());
$data = [
'id' => ($lead) ? $lead->getId() : null,
'sid' => $trackingId,
'device_id' => $trackingId,
];
}
return new JsonResponse($data);
}
/**
* @return array<string,bool>
*/
private function getPreferenceCenterConfig(): array
{
return [
'showContactFrequency' => $this->coreParametersHelper->get('show_contact_frequency'),
'showContactPauseDates' => $this->coreParametersHelper->get('show_contact_pause_dates'),
'showContactPreferredChannels' => $this->coreParametersHelper->get('show_contact_preferred_channels'),
'showContactCategories' => $this->coreParametersHelper->get('show_contact_categories'),
'showContactSegments' => $this->coreParametersHelper->get('show_contact_segments'),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\PageBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CategoryBundle\Model\CategoryModel;
class LoadPageCategoryData extends AbstractFixture implements OrderedFixtureInterface
{
public function __construct(
private CategoryModel $categoryModel,
) {
}
public function load(ObjectManager $manager): void
{
$today = new \DateTime();
$cat = new Category();
$events = 'Events';
$cat->setBundle('page');
$cat->setDateAdded($today);
$cat->setTitle($events);
$cat->setAlias(strtolower($events));
$this->categoryModel->getRepository()->saveEntity($cat);
$this->setReference('page-cat-1', $cat);
}
public function getOrder()
{
return 6;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\PageBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Helper\Serializer;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Model\PageModel;
class LoadPageData extends AbstractFixture implements OrderedFixtureInterface
{
public function __construct(
private PageModel $pageModel,
) {
}
public function load(ObjectManager $manager): void
{
$pages = CsvHelper::csv_to_array(__DIR__.'/fakepagedata.csv');
foreach ($pages as $count => $rows) {
$page = new Page();
$key = $count + 1;
foreach ($rows as $col => $val) {
if ('NULL' != $val) {
$setter = 'set'.ucfirst($col);
if (in_array($col, ['translationParent', 'variantParent'])) {
$page->$setter($this->getReference('page-'.$val));
} elseif (in_array($col, ['dateAdded', 'variantStartDate'])) {
$page->$setter(new \DateTime($val));
} elseif (in_array($col, ['content', 'variantSettings'])) {
$val = Serializer::decode(stripslashes($val));
$page->$setter($val);
} else {
$page->$setter($val);
}
}
}
$page->setCategory($this->getReference('page-cat-1'));
$this->pageModel->getRepository()->saveEntity($page);
$this->setReference('page-'.$key, $page);
}
}
public function getOrder()
{
return 7;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\PageBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Helper\Serializer;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Model\PageModel;
class LoadPageHitData extends AbstractFixture implements OrderedFixtureInterface
{
public function __construct(
private PageModel $pageModel,
) {
}
public function load(ObjectManager $manager): void
{
$hits = CsvHelper::csv_to_array(__DIR__.'/fakepagehitdata.csv');
foreach ($hits as $rows) {
$hit = new Hit();
foreach ($rows as $col => $val) {
if ('NULL' != $val) {
$setter = 'set'.ucfirst($col);
if (in_array($col, ['page', 'ipAddress'])) {
$hit->$setter($this->getReference($col.'-'.$val));
} elseif (in_array($col, ['dateHit', 'dateLeft'])) {
$hit->$setter(new \DateTime($val));
} elseif ('browserLanguages' == $col) {
$val = Serializer::decode(stripslashes($val));
$hit->$setter($val);
} else {
$hit->$setter($val);
}
}
}
$this->pageModel->getRepository()->saveEntity($hit);
}
}
public function getOrder()
{
return 8;
}
}

View File

@@ -0,0 +1,4 @@
translationParent,variantParent,isPublished,dateAdded,title,alias,template,language,content,hits,uniqueHits,variantHits,revision,metaDescription,variantSettings,variantStartDate
NULL,NULL,1,8/9/14 0:00,Kaleidoscope Conference 2014,kaleidoscope-conference-2014,blank,en,"a:5:{s:4:\"top2\";s:151:\"<div><h1><span style=\"font-family:comic sans\"><span style=\"color:#0000FF\"><span dir=\"auto\">Kaleidoscope Conference 2014</span></span></span></h1></div>\";s:4:\"main\";s:44:\"<div>Sign up today!</div><div>{form=1}</div>\";s:6:\"footer\";s:0:\"\";s:6:\"right1\";s:0:\"\";s:7:\"bottom3\";s:0:\"\";}",31,28,28,8,"Join your fellow kaleidoscopians at the 2014 Kaleidoscope Conference. Learn new techniques, attend workshops, share ideas with others, or just hang out!",a:0:{},8/9/14 0:00
NULL,1,1,8/9/14 0:12,Kaleidoscope Conference 2014 v2,kaleidoscope-conference-2014,blank,en,"a:5:{s:4:\"top2\";s:151:\"<div><h1><span style=\"font-family:comic sans\"><span style=\"color:#0000FF\"><span dir=\"auto\">Kaleidoscope Conference 2014</span></span></span></h1></div>\";s:4:\"main\";s:153:\"<div>Don&#39;t be afraid to reach out to your inner kid once again! Let your creativity roll at the annual Kaleidoscope Conference. Register today!</div>\";s:6:\"footer\";s:0:\"\";s:6:\"right1\";s:55:\"<div><div>Sign up today!</div><div>{form=1}</div></div>\";s:7:\"bottom3\";s:0:\"\";}",95,85,85,4,"Join your fellow kaleidoscopians at the 2014 Kaleidoscope Conference. Learn new techniques, attend workshops, share ideas with others, or just hang out!","a:2:{s:6:\"weight\";i:50;s:14:\"winnerCriteria\";s:14:\"page.dwelltime\";}",8/9/14 0:00
1,NULL,1,8/9/14 1:07,Kaleidoscope Conferencia 2014,kaleidoscope-conference-2014,blank,es_MX,"a:5:{s:4:\"top2\";s:151:\"<div><h1><span style=\"font-family:comic sans\"><span style=\"color:#0000FF\"><span dir=\"auto\">Kaleidoscope Conference 2014</span></span></span></h1></div>\";s:4:\"main\";s:44:\"<div>Sign up today!</div><div>{form=2}</div>\";s:6:\"footer\";s:0:\"\";s:6:\"right1\";s:0:\"\";s:7:\"bottom3\";s:0:\"\";}",1,1,1,9,"Únete a tus compañeros kaleidoscopians en la Conferencia de 2014 Kaleidoscope. Aprenda nuevas técnicas, asistir a talleres, compartir ideas con los demás, o simplemente pasar el rato!",a:0:{},NULL
Can't render this file because it contains an unexpected character in line 2 and column 103.

View File

@@ -0,0 +1,131 @@
page,ipAddress,dateHit,dateLeft,code,referer,url,userAgent,pageLanguage,browserLanguages,trackingId
3,1,8/9/14 19:00,8/9/14 19:01,200,http://localhost/mautic/pages/view/3,http://localhost/mautic/p/page/es_MX/3:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,es_MX,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",H764T844
1,38,8/10/14 0:22,8/10/14 0:22,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",5KPMMUCC
1,36,8/10/14 0:22,8/10/14 0:22,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",5KPMMUCC
1,15,8/10/14 0:22,8/10/14 0:22,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",E2Q6OBTT
1,18,8/10/14 0:23,8/10/14 0:23,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",RZT87U00
1,42,8/10/14 0:23,8/10/14 0:23,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",RZT87U00
1,5,8/10/14 0:23,8/10/14 0:23,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",YOQLXM44
1,1,8/10/14 0:23,8/10/14 0:23,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",OBKQCCRR
1,39,8/10/14 0:23,8/10/14 0:23,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",OBKQCCRR
1,42,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",SUDI7EZZ
1,42,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",6QM3ZGEE
1,32,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",JBW4CYRR
1,37,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",IYPAQXX
1,35,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",RDDQEK44
1,14,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",3GLTTX00
1,15,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",Z7H6S6CC
1,32,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",ZIPRWGMM
1,15,8/10/14 0:24,8/10/14 0:24,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",9WOPU3WW
1,30,8/10/14 0:26,8/10/14 0:26,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",9QW2PE44
1,2,8/10/14 0:26,8/10/14 0:26,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",KT7FHXSS
1,20,8/10/14 0:26,8/10/14 0:26,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",D3O0F7NN
1,43,8/10/14 0:27,8/10/14 0:27,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",QRT1O6VV
1,4,8/10/14 0:27,8/10/14 0:27,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",CLRK3CMM
1,43,8/10/14 0:27,8/10/14 0:27,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",4H7BVT22
1,2,8/10/14 0:27,8/10/14 0:28,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",6F8ZXEE
1,31,8/10/14 0:30,8/10/14 0:30,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",WYY3KF66
2,46,8/10/14 0:30,8/10/14 0:30,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",HSZ4NWKK
2,36,8/10/14 0:30,8/10/14 0:30,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4M9RF144
2,45,8/10/14 0:30,8/10/14 0:30,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",GIYIEJ22
2,14,8/10/14 0:30,8/10/14 0:30,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",OPAIDMM
2,35,8/10/14 0:30,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",P9JOSSVV
2,33,8/10/14 0:30,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",Q8SLO5YY
2,8,8/10/14 0:30,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",OGRXTRQQ
2,42,8/10/14 0:30,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",1R45QTUU
2,35,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",OB1QZ144
2,47,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",UZ00ZR66
2,31,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",KRQ2F4FF
2,15,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",NVN2JEFF
2,28,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",UJFR71NN
2,47,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",CZ69ZOUU
2,2,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",Z03WC1OO
2,15,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",YLUNOS33
2,21,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4Q4L3EDD
2,9,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",F73DD644
2,33,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",RVOJXM00
2,36,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",WYRHCFF
2,30,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",PX57555
2,44,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",0IBIS0GG
2,28,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",LFOHPMNN
2,9,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",74CM5J99
2,9,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4EZBX0GG
2,19,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",MKEVKDZZ
2,19,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",99LNEEKK
2,35,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",QDPXP9
2,18,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",ICW7BS77
2,36,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",EFAHDC99
2,24,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",LM4J9PII
2,11,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",2S3J1K66
2,34,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3EXUCLEE
2,37,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",W5TR0BRR
2,31,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3I4TC288
2,44,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",IQLVMMUU
2,25,8/10/14 0:31,8/10/14 0:31,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",XC5ZPDXX
2,43,8/10/14 0:32,8/10/14 0:32,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",WNOL0UZZ
2,39,8/10/14 0:32,8/10/14 0:32,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",KOFQ3044
2,17,8/10/14 0:32,8/10/14 0:32,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",GI26RS66
2,17,8/10/14 0:32,8/10/14 0:32,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",GQEN0GEE
2,33,8/10/14 0:40,8/10/14 0:40,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",ONZRBVGG
2,14,8/10/14 0:40,8/10/14 0:41,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",N4MLY555
1,22,8/10/14 0:47,8/10/14 0:47,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",AGEYKUYY
2,15,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",TOWNL9UU
2,10,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3SN3WPJJ
2,4,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",RQY64ZHH
2,41,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",O3BDIVWW
2,40,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",G2JULRR
2,27,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",9Q4LJOWW
2,14,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",ZKDZ9N00
2,37,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",JLPUNPII
2,46,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",ATVT2KUU
2,16,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",6L5GEWII
2,43,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",C0EX4933
2,17,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",1JWEEY
2,6,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",HMN1FZ77
2,29,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4V37K977
2,24,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",GBDDG0JJ
2,35,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",8EJ7OW99
2,1,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",Y21VCNLL
2,1,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4OOI0C66
2,2,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3XQD0E33
2,6,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",W5PNQ9CC
2,25,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",11BBNP
2,5,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3QVAHF11
2,49,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",4DCW3PVV
2,28,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",0EZB9OCC
2,46,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",ODB8SKHH
2,44,8/10/14 0:47,8/10/14 0:47,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",F744CMII
1,32,8/10/14 1:08,8/10/14 1:08,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",ZGWS2Z33
2,26,8/10/14 1:08,8/10/14 1:08,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",95XW2JVV
2,34,8/10/14 1:11,8/10/14 1:11,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",C7EYGG66
2,41,8/10/14 1:11,8/10/14 1:11,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",J6C2KJJ
2,2,8/10/14 1:11,8/10/14 1:11,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",1US5SPBB
2,39,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",CW6YD533
2,38,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",FY9FEAWW
2,24,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",Z4LXDL66
2,3,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",IJPE4Z33
2,45,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",3L76FSS
2,13,8/10/14 1:12,8/10/14 1:12,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",UD2OGC44
2,31,8/10/14 1:12,8/10/14 1:13,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",Z36Y96YY
2,17,8/10/14 1:12,8/10/14 1:13,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",IX3S9766
2,38,8/10/14 1:13,8/10/14 1:13,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",OFX17FUU
2,42,8/10/14 1:13,8/10/14 1:13,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",7259A4YY
2,10,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",TK9649UU
2,25,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",9NZ8HLWW
2,31,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",8N6RW177
2,45,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",OC6LS7RR
2,26,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",QTZ8XMM
2,1,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",RC7GLHQQ
2,16,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",MPFEO811
2,29,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",PSOP6XOO
2,36,8/10/14 1:15,8/10/14 1:15,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",7K6Z7LXX
2,41,8/10/14 1:17,8/10/14 1:18,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",53e6bd3323eb9
2,48,8/10/14 1:18,8/10/14 1:18,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",53e6bd3323eb9
1,50,8/10/14 1:20,8/10/14 1:21,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53e6bb48e5053
1,18,8/10/14 1:20,8/10/14 1:20,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53e6bb48e5053
2,5,8/10/14 1:48,8/10/14 1:48,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.77.4 (KHTML, like Gecko) Version/7.0.5 Safari/537.77.4",en,"a:1:{i:0;s:5:\"en-US\";}",53e6bd3323eb9
1,14,8/12/14 19:51,8/12/14 19:51,200,http://localhost/mautic/pages/view/1,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53ea6d090aab5
2,9,8/12/14 19:51,8/12/14 19:51,200,NULL,http://localhost/mautic/p/page/2:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53ea6d090aab5
2,7,8/12/14 20:02,8/12/14 20:02,200,NULL,http://localhost/mautic/p/page/2:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53ea72c1c0232
1,19,8/12/14 20:02,8/12/14 20:02,200,NULL,http://localhost/mautic/p/page/1:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53ea72c1c0232
2,35,8/12/14 20:02,8/12/14 20:02,200,NULL,http://localhost/mautic/p/page/2:kaleidoscope-conference-2014,Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:31.0) Gecko/20100101 Firefox/31.0,en,"a:2:{i:0;s:5:\"en-US\";i:1;s:8:\"en;q=0.5\";}",53ea72c1c0232
Can't render this file because it contains an unexpected character in line 2 and column 243.

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticPageExtension 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,844 @@
<?php
namespace Mautic\PageBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\EmailBundle\Entity\Email;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadDevice;
class Hit
{
public const TABLE_NAME = 'page_hits';
/**
* @var string
*/
private $id;
/**
* @var \DateTimeInterface
*/
private $dateHit;
/**
* @var \DateTimeInterface
*/
private $dateLeft;
private ?Page $page = null;
/**
* @var Redirect|null
*/
private $redirect;
/**
* @var Email|null
*/
private $email;
/**
* @var Lead|null
*/
private $lead;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var string|null
*/
private $country;
/**
* @var string|null
*/
private $region;
/**
* @var string|null
*/
private $city;
/**
* @var string|null
*/
private $isp;
/**
* @var string|null
*/
private $organization;
/**
* @var int
*/
private $code;
private $referer;
private $url;
/**
* @var string|null
*/
private $urlTitle;
/**
* @var string|null
*/
private $userAgent;
/**
* @var string|null
*/
private $remoteHost;
/**
* @var string|null
*/
private $pageLanguage;
/**
* @var array<string>
*/
private $browserLanguages = [];
/**
* @var string
**/
private $trackingId;
/**
* @var string|null
*/
private $source;
/**
* @var int|null
*/
private $sourceId;
/**
* @var array
*/
private $query = [];
/**
* @var LeadDevice|null
*/
private $device;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(HitRepository::class)
->addIndex(['tracking_id'], 'page_hit_tracking_search')
->addIndex(['code'], 'page_hit_code_search')
->addIndex(['source', 'source_id'], 'page_hit_source_search')
->addIndex(['date_hit', 'date_left'], 'date_hit_left_index')
->addIndexWithOptions(['url'], 'page_hit_url', ['lengths' => [0 => 128]]);
$builder->addBigIntIdField();
$builder->createField('dateHit', 'datetime')
->columnName('date_hit')
->build();
$builder->createField('dateLeft', 'datetime')
->columnName('date_left')
->nullable()
->build();
$builder->createManyToOne('page', 'Page')
->addJoinColumn('page_id', 'id', true, false, 'SET NULL')
->build();
$builder->createManyToOne('redirect', 'Redirect')
->addJoinColumn('redirect_id', 'id', true, false, 'SET NULL')
->build();
$builder->createManyToOne('email', Email::class)
->addJoinColumn('email_id', 'id', true, false, 'SET NULL')
->build();
$builder->addLead(true, 'SET NULL');
$builder->addIpAddress(true);
$builder->createField('country', 'string')
->nullable()
->build();
$builder->createField('region', 'string')
->nullable()
->build();
$builder->createField('city', 'string')
->nullable()
->build();
$builder->createField('isp', 'string')
->nullable()
->build();
$builder->createField('organization', 'string')
->nullable()
->build();
$builder->addField('code', 'integer');
$builder->createField('referer', 'text')
->nullable()
->build();
$builder->createField('url', 'text')
->nullable()
->build();
$builder->createField('urlTitle', 'string')
->columnName('url_title')
->nullable()
->build();
$builder->createField('userAgent', 'text')
->columnName('user_agent')
->nullable()
->build();
$builder->createField('remoteHost', 'string')
->columnName('remote_host')
->nullable()
->build();
$builder->createField('pageLanguage', 'string')
->columnName('page_language')
->nullable()
->build();
$builder->createField('browserLanguages', 'array')
->columnName('browser_languages')
->nullable()
->build();
$builder->createField('trackingId', 'string')
->columnName('tracking_id')
->build();
$builder->createField('source', 'string')
->nullable()
->build();
$builder->createField('sourceId', 'integer')
->columnName('source_id')
->nullable()
->build();
$builder->addNullableField('query', 'array');
$builder->createManyToOne('device', LeadDevice::class)
->addJoinColumn('device_id', 'id', true, false, 'SET NULL')
->cascadePersist()
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('hit')
->addProperties(
[
'id',
'dateHit',
'dateLeft',
'page',
'redirect',
'email',
'lead',
'ipAddress',
'country',
'region',
'city',
'isp',
'organization',
'code',
'referer',
'url',
'urlTitle',
'userAgent',
'remoteHost',
'pageLanguage',
'browserLanguages',
'trackingId',
'source',
'sourceId',
'query',
]
)
->build();
}
/**
* Get id.
*/
public function getId(): int
{
return (int) $this->id;
}
/**
* Set dateHit.
*
* @param \DateTime $dateHit
*
* @return Hit
*/
public function setDateHit($dateHit)
{
$this->dateHit = $dateHit;
return $this;
}
/**
* Get dateHit.
*
* @return \DateTimeInterface
*/
public function getDateHit()
{
return $this->dateHit;
}
/**
* @return \DateTimeInterface
*/
public function getDateLeft()
{
return $this->dateLeft;
}
/**
* @param \DateTime $dateLeft
*
* @return Hit
*/
public function setDateLeft($dateLeft)
{
$this->dateLeft = $dateLeft;
return $this;
}
/**
* Set country.
*
* @param string $country
*
* @return Hit
*/
public function setCountry($country)
{
$this->country = $country;
return $this;
}
/**
* Get country.
*
* @return string
*/
public function getCountry()
{
return $this->country;
}
/**
* Set region.
*
* @param string $region
*
* @return Hit
*/
public function setRegion($region)
{
$this->region = $region;
return $this;
}
/**
* Get region.
*
* @return string
*/
public function getRegion()
{
return $this->region;
}
/**
* Set city.
*
* @param string $city
*
* @return Hit
*/
public function setCity($city)
{
$this->city = $city;
return $this;
}
/**
* Get city.
*
* @return string
*/
public function getCity()
{
return $this->city;
}
/**
* Set isp.
*
* @param string $isp
*
* @return Hit
*/
public function setIsp($isp)
{
$this->isp = $isp;
return $this;
}
/**
* Get isp.
*
* @return string
*/
public function getIsp()
{
return $this->isp;
}
/**
* Set organization.
*
* @param string $organization
*
* @return Hit
*/
public function setOrganization($organization)
{
$this->organization = $organization;
return $this;
}
/**
* Get organization.
*
* @return string
*/
public function getOrganization()
{
return $this->organization;
}
/**
* Set code.
*
* @param int $code
*
* @return Hit
*/
public function setCode($code)
{
$this->code = $code;
return $this;
}
/**
* Get code.
*
* @return int
*/
public function getCode()
{
return $this->code;
}
/**
* Set referer.
*
* @param string $referer
*
* @return Hit
*/
public function setReferer($referer)
{
$this->referer = $referer;
return $this;
}
/**
* Get referer.
*
* @return string
*/
public function getReferer()
{
return $this->referer;
}
/**
* Set url.
*
* @param string $url
*
* @return Hit
*/
public function setUrl($url)
{
$this->url = $url;
return $this;
}
/**
* Get url.
*
* @return string
*/
public function getUrl()
{
return $this->url;
}
/**
* Set url title.
*
* @param string $urlTitle
*
* @return Hit
*/
public function setUrlTitle($urlTitle)
{
$urlTitle = mb_strlen($urlTitle) <= 191 ? $urlTitle : mb_substr($urlTitle, 0, 191);
$this->urlTitle = $urlTitle;
return $this;
}
/**
* Get url title.
*
* @return string
*/
public function getUrlTitle()
{
return $this->urlTitle;
}
/**
* Set userAgent.
*
* @param string $userAgent
*
* @return Hit
*/
public function setUserAgent($userAgent)
{
$this->userAgent = $userAgent;
return $this;
}
/**
* Get userAgent.
*
* @return string
*/
public function getUserAgent()
{
return $this->userAgent;
}
/**
* Set remoteHost.
*
* @param string $remoteHost
*
* @return Hit
*/
public function setRemoteHost($remoteHost)
{
$this->remoteHost = $remoteHost;
return $this;
}
/**
* Get remoteHost.
*
* @return string
*/
public function getRemoteHost()
{
return $this->remoteHost;
}
/**
* Set page.
*
* @return Hit
*/
public function setPage(?Page $page = null)
{
$this->page = $page;
return $this;
}
/**
* @return ?Page
*/
public function getPage()
{
return $this->page;
}
/**
* @return Hit
*/
public function setIpAddress(IpAddress $ipAddress)
{
$this->ipAddress = $ipAddress;
return $this;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param string $trackingId
*
* @return Hit
*/
public function setTrackingId($trackingId)
{
$this->trackingId = $trackingId;
return $this;
}
/**
* Get trackingId.
*
* @return string|null
*/
public function getTrackingId()
{
return $this->trackingId;
}
/**
* Set pageLanguage.
*
* @param string $pageLanguage
*
* @return Hit
*/
public function setPageLanguage($pageLanguage)
{
$this->pageLanguage = $pageLanguage;
return $this;
}
/**
* Get pageLanguage.
*
* @return string
*/
public function getPageLanguage()
{
return $this->pageLanguage;
}
/**
* Set browserLanguages.
*
* @param array<string> $browserLanguages
*
* @return Hit
*/
public function setBrowserLanguages($browserLanguages)
{
$this->browserLanguages = $browserLanguages;
return $this;
}
/**
* Get browserLanguages.
*
* @return array<string>
*/
public function getBrowserLanguages()
{
return $this->browserLanguages;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @return Hit
*/
public function setLead(Lead $lead)
{
$this->lead = $lead;
return $this;
}
/**
* @return string
*/
public function getSource()
{
return $this->source;
}
/**
* @param string $source
*
* @return Hit
*/
public function setSource($source)
{
$this->source = $source;
return $this;
}
/**
* @return int
*/
public function getSourceId()
{
return $this->sourceId;
}
/**
* @param int $sourceId
*
* @return Hit
*/
public function setSourceId($sourceId)
{
$this->sourceId = (int) $sourceId;
return $this;
}
/**
* @return Redirect
*/
public function getRedirect()
{
return $this->redirect;
}
/**
* @return Hit
*/
public function setRedirect(Redirect $redirect)
{
$this->redirect = $redirect;
return $this;
}
/**
* @return mixed
*/
public function getEmail()
{
return $this->email;
}
/**
* @param mixed $email
*/
public function setEmail(Email $email): void
{
$this->email = $email;
}
/**
* @return array
*/
public function getQuery()
{
return $this->query;
}
/**
* @param array $query
*
* @return Hit
*/
public function setQuery($query)
{
$this->query = $query;
return $this;
}
/**
* @return LeadDevice
*/
public function getDeviceStat()
{
return $this->device;
}
/**
* @return Hit
*/
public function setDeviceStat(LeadDevice $device)
{
$this->device = $device;
return $this;
}
}

View File

@@ -0,0 +1,557 @@
<?php
namespace Mautic\PageBundle\Entity;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Hit>
*/
class HitRepository extends CommonRepository
{
use TimelineTrait;
/**
* Determine if the page hit is a unique.
*
* @param Page|Redirect $page
* @param string $trackingId
*/
public function isUniquePageHit($page, $trackingId, ?Lead $lead = null): bool
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q2 = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q2->select('null')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h');
// If we know the lead, use that to determine uniqueness
if (null !== $lead && $lead->getId()) {
$expr = CompositeExpression::and($q2->expr()->eq('h.lead_id', $lead->getId()));
} else {
$expr = CompositeExpression::and($q2->expr()->eq('h.tracking_id', ':id'));
$q->setParameter('id', $trackingId);
}
if ($page instanceof Page) {
$expr = $expr->with(
$q2->expr()->eq('h.page_id', $page->getId())
);
} elseif ($page instanceof Redirect) {
$expr = $expr->with(
$q2->expr()->eq('h.redirect_id', $page->getId())
);
}
$q2->where($expr);
$q->select('u.is_unique')
->from(sprintf('(SELECT (NOT EXISTS (%s)) is_unique)', $q2->getSQL()), 'u');
return (bool) $q->executeQuery()->fetchOne();
}
/**
* Get a lead's page hits.
*
* @param int|null $leadId
*
* @return array
*/
public function getLeadHits($leadId = null, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->select('h.id as hitId, h.page_id, h.user_agent as userAgent, h.date_hit as dateHit, h.date_left as dateLeft, h.referer, h.source, h.source_id as sourceId, h.url, h.url_title as urlTitle, h.query, ds.client_info as clientInfo, ds.device, ds.device_os_name as deviceOsName, ds.device_brand as deviceBrand, ds.device_model as deviceModel, h.lead_id')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h')
->leftJoin('h', MAUTIC_TABLE_PREFIX.'pages', 'p', 'h.page_id = p.id');
if ($leadId) {
$query->where('h.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->like('p.title', ':search')
)->setParameter('search', '%'.$options['search'].'%');
}
$query->leftjoin('h', MAUTIC_TABLE_PREFIX.'lead_devices', 'ds', 'ds.id = h.device_id');
if (isset($options['url']) && $options['url']) {
$query->andWhere($query->expr()->eq('h.url', $query->expr()->literal($options['url'])));
}
return $this->getTimelineResults($query, $options, 'p.title', 'h.date_hit', ['query'], ['dateHit', 'dateLeft'], null, 'h.id');
}
/**
* @return array
*/
public function getHitCountForSource($source, $sourceId = null, $fromDate = null, $code = 200)
{
$query = $this->createQueryBuilder('h');
$query->select('count(distinct(h.trackingId)) as hitCount');
$query->andWhere($query->expr()->eq('h.source', $query->expr()->literal($source)));
if (null != $sourceId) {
if (is_array($sourceId)) {
$query->andWhere($query->expr()->in('h.sourceId', ':sourceIds'))
->setParameter('sourceIds', $sourceId);
} else {
$query->andWhere('h.sourceId = :sourceId')
->setParameter('sourceId', $sourceId);
}
}
if (null != $fromDate) {
$query->andwhere($query->expr()->gte('h.dateHit', ':date'))
->setParameter('date', $fromDate);
}
$query->andWhere('h.code = :code')
->setParameter('code', $code);
return $query->getQuery()->getArrayResult();
}
/**
* Get an array of hits via an email clickthrough.
*
* @param int $code
*/
public function getEmailClickthroughHitCount($emailIds, ?\DateTime $fromDate = null, $code = 200): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
if (!is_array($emailIds)) {
$emailIds = [$emailIds];
}
$q->select('count(distinct(h.tracking_id)) as hit_count, h.email_id')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h')
->where($q->expr()->in('h.email_id', $emailIds))
->groupBy('h.email_id');
if (null != $fromDate) {
$dateHelper = new DateTimeHelper($fromDate);
$q->andwhere($q->expr()->gte('h.date_hit', ':date'))
->setParameter('date', $dateHelper->toUtcString());
}
$q->andWhere($q->expr()->eq('h.code', (int) $code));
$results = $q->executeQuery()->fetchAllAssociative();
$hits = [];
foreach ($results as $r) {
$hits[$r['email_id']] = $r['hit_count'];
}
return $hits;
}
/**
* Count returning IP addresses.
*/
public function countReturningIp(): int
{
$q = $this->createQueryBuilder('h');
$q->select('COUNT(h.ipAddress) as returning')
->groupBy('h.ipAddress')
->having($q->expr()->gt('COUNT(h.ipAddress)', 1));
$results = $q->getQuery()->getResult();
return count($results);
}
/**
* Count email clickthrough.
*
* @return int
*/
public function countEmailClickthrough()
{
$q = $this->createQueryBuilder('h');
$q->select('COUNT(h.email) as clicks');
$results = $q->getQuery()->getSingleResult();
return $results['clicks'];
}
/**
* Count how many visitors hit some page in last X $seconds.
*
* @param int $seconds
* @param bool $notLeft
*/
public function countVisitors($seconds = 60, $notLeft = false): int
{
$now = new \DateTime();
$viewingTime = new \DateInterval('PT'.$seconds.'S');
$now->sub($viewingTime);
$query = $this->createQueryBuilder('h');
$query->select('count(h.code) as visitors');
if ($seconds) {
$query->where($query->expr()->gte('h.dateHit', ':date'))
->setParameter('date', $now);
}
if ($notLeft) {
$query->andWhere($query->expr()->isNull('h.dateLeft'));
}
$result = $query->getQuery()->getSingleResult();
if (!isset($result['visitors'])) {
return 0;
}
return (int) $result['visitors'];
}
/**
* Get the latest hit.
*
* @param array $options
*/
public function getLatestHit($options): ?\DateTime
{
$sq = $this->_em->getConnection()->createQueryBuilder();
$sq->select('h.date_hit latest_hit')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h');
if (isset($options['leadId'])) {
$sq->andWhere(
$sq->expr()->eq('h.lead_id', $options['leadId'])
);
}
if (isset($options['urls']) && $options['urls']) {
$inUrls = (!is_array($options['urls'])) ? [$options['urls']] : $options['urls'];
foreach ($inUrls as $k => $u) {
$sq->andWhere($sq->expr()->like('h.url', ':url_'.$k))
->setParameter('url_'.$k, $u);
}
}
if (isset($options['second_to_last'])) {
$sq->andWhere($sq->expr()->neq('h.id', $options['second_to_last']));
} else {
$sq->orderBy('h.date_hit', 'DESC limit 1');
}
$result = $sq->executeQuery()->fetchAssociative();
return $result ? new \DateTime($result['latest_hit'], new \DateTimeZone('UTC')) : null;
}
/**
* Get the number of bounces.
*
* @param array|string $pageIds
* @param bool $isVariantCheck
*
* @return mixed[]
*/
public function getBounces($pageIds, ?\DateTime $fromDate = null, $isVariantCheck = false): array
{
$inOrEq = (!is_array($pageIds)) ? 'eq' : 'in';
$hitsColumn = ($isVariantCheck) ? 'variant_hits' : 'unique_hits';
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$pages = $q->select("p.id, p.$hitsColumn as totalHits, p.title")
->from(MAUTIC_TABLE_PREFIX.'pages', 'p')
->where($q->expr()->$inOrEq('p.id', $pageIds))
->executeQuery()
->fetchAllAssociative();
$return = [];
foreach ($pages as $p) {
$return[$p['id']] = [
'totalHits' => (int) $p['totalHits'],
'bounces' => 0,
'rate' => 0,
'title' => $p['title'],
];
}
// Get the total number of bounces - simplified query for if date_left is null, it'll more than likely be a bounce or
// else we would have recorded the date_left on a subsequent page hit
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$expr = $q->expr()->and(
$q->expr()->$inOrEq('h.page_id', $pageIds),
$q->expr()->eq('h.code', 200),
$q->expr()->isNull('h.date_left')
);
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate, 'Y-m-d H:i:s', 'local');
$expr = $expr->with(
$q->expr()->gte('h.date_hit', $q->expr()->literal($dt->toUtcString()))
);
}
$q->select('count(*) as bounces, h.page_id')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h')
->where($expr)
->groupBy('h.page_id');
$results = $q->executeQuery()->fetchAllAssociative();
foreach ($results as $p) {
$return[$p['page_id']]['bounces'] = (int) $p['bounces'];
$return[$p['page_id']]['rate'] = ($return[$p['page_id']]['totalHits']) ? round(
($p['bounces'] / $return[$p['page_id']]['totalHits']) * 100,
2
) : 0;
}
return (!is_array($pageIds)) ? $return[$pageIds] : $return;
}
/**
* Get array of dwell time labels with ranges.
*/
public function getDwellTimeLabels(): array
{
return [
[
'label' => '< 1m',
'from' => 0,
'till' => 60,
],
[
'label' => '1 - 5m',
'from' => 60,
'till' => 300,
],
[
'label' => '5 - 10m',
'value' => 0,
'from' => 300,
'till' => 600,
],
[
'label' => '> 10m',
'from' => 600,
'till' => 999999,
],
];
}
/**
* Get the dwell times for bunch of pages.
*/
public function getDwellTimesForPages(array $pageIds, array $options): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->leftJoin('ph', MAUTIC_TABLE_PREFIX.'pages', 'p', 'ph.page_id = p.id')
->select('ph.page_id, ph.date_hit, ph.date_left, p.title')
->orderBy('ph.date_hit', 'ASC')
->andWhere(
$q->expr()->and(
$q->expr()->in('ph.page_id', $pageIds)
)
);
if (isset($options['fromDate'])) {
// make sure the date is UTC
$dt = new DateTimeHelper($options['fromDate']);
$q->andWhere(
$q->expr()->gte('ph.date_hit', $q->expr()->literal($dt->toUtcString()))
);
}
$results = $q->executeQuery()->fetchAllAssociative();
// loop to structure
$times = [];
$titles = [];
foreach ($results as $r) {
$dateHit = $r['date_hit'] ? new \DateTime($r['date_hit']) : 0;
$dateLeft = $r['date_left'] ? new \DateTime($r['date_left']) : 0;
$titles[$r['page_id']] = $r['title'];
$times[$r['page_id']][] = $dateLeft ? ($dateLeft->getTimestamp() - $dateHit->getTimestamp()) : 0;
}
// now loop to create stats
$stats = [];
foreach ($times as $pid => $time) {
$stats[$pid] = $this->countStats($time);
$stats[$pid]['title'] = $titles[$pid];
}
return $stats;
}
/**
* Get the dwell times for bunch of URLs.
*
* @param string $url
*/
public function getDwellTimesForUrl($url, array $options): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->leftJoin('ph', MAUTIC_TABLE_PREFIX.'pages', 'p', 'ph.page_id = p.id')
->select('ph.id, ph.page_id, ph.date_hit, ph.date_left, ph.tracking_id, ph.page_language, p.title')
->orderBy('ph.date_hit', 'ASC')
->andWhere($q->expr()->like('ph.url', ':url'))
->setParameter('url', $url);
if (isset($options['leadId']) && $options['leadId']) {
$q->andWhere(
$q->expr()->eq('ph.lead_id', (int) $options['leadId'])
);
}
$results = $q->executeQuery()->fetchAllAssociative();
$times = [];
foreach ($results as $r) {
$dateHit = $r['date_hit'] ? new \DateTime($r['date_hit']) : 0;
$dateLeft = $r['date_left'] ? new \DateTime($r['date_left']) : 0;
$times[] = $dateLeft ? ($dateLeft->getTimestamp() - $dateHit->getTimestamp()) : 0;
}
return $this->countStats($times);
}
/**
* Count stats from hit times.
*
* @param array $times
*/
public function countStats($times): array
{
return [
'sum' => array_sum($times),
'min' => count($times) ? min($times) : 0,
'max' => count($times) ? max($times) : 0,
'average' => count($times) ? round(array_sum($times) / count($times)) : 0,
'count' => count($times),
];
}
/**
* Update a hit with the the time the user left.
*
* @param int $lastHitId
*/
public function updateHitDateLeft($lastHitId): void
{
$dt = new DateTimeHelper();
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'page_hits')
->set('date_left', ':datetime')
->where('id = '.(int) $lastHitId)
->setParameter('datetime', $dt->toUtcString());
$q->executeStatement();
}
/**
* Get list of referers ordered by it's count.
*
* @param \Doctrine\DBAL\Query\QueryBuilder $query
* @param int $limit
* @param int $offset
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getReferers($query, $limit = 10, $offset = 0): array
{
$query->select('ph.referer, count(ph.referer) as sessions')
->groupBy('ph.referer')
->orderBy('sessions', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Get list of referers ordered by it's count.
*
* @param \Doctrine\DBAL\Query\QueryBuilder $query
* @param int $limit
* @param int $offset
* @param string $column
* @param string $as
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getMostVisited($query, $limit = 10, $offset = 0, $column = 'p.hits', $as = ''): array
{
if ($as) {
$as = ' as "'.$as.'"';
}
$query->select('p.title, p.id, '.$column.$as)
->where('p.id IS NOT NULL')
->groupBy('p.id, p.title, '.$column)
->orderBy($column, 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
public function updateLeadByTrackingId($leadId, $newTrackingId, $oldTrackingId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'page_hits')
->set('lead_id', (int) $leadId)
->set('tracking_id', ':newTrackingId')
->where(
$q->expr()->eq('tracking_id', ':oldTrackingId')
)
->setParameters([
'newTrackingId' => $newTrackingId,
'oldTrackingId' => $oldTrackingId,
])
->executeStatement();
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'page_hits')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
public function getLatestHitDateByLead(int $leadId, ?string $trackingId = null): ?\DateTime
{
$q = $this->_em->getConnection()->createQueryBuilder()
->select('MAX(date_hit)')
->from(MAUTIC_TABLE_PREFIX.'page_hits')
->where('lead_id = :leadId')
->setParameter('leadId', $leadId);
if (null != $trackingId) {
$q->andWhere('tracking_id = :trackingId')
->setParameter('trackingId', $trackingId);
}
$result = $q->executeQuery()->fetchOne();
return $result ? new \DateTime($result, new \DateTimeZone('UTC')) : null;
}
}

View File

@@ -0,0 +1,957 @@
<?php
namespace Mautic\PageBundle\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\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\TranslationEntityInterface;
use Mautic\CoreBundle\Entity\TranslationEntityTrait;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\CoreBundle\Entity\VariantEntityInterface;
use Mautic\CoreBundle\Entity\VariantEntityTrait;
use Mautic\CoreBundle\Validator\EntityEvent;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('page:pages:viewown')"),
new Post(security: "is_granted('page:pages:create')"),
new Get(security: "is_granted('page:pages:viewown')"),
new Put(security: "is_granted('page:pages:editown')"),
new Patch(security: "is_granted('page:pages:editother')"),
new Delete(security: "is_granted('page:pages:deleteown')"),
],
normalizationContext: [
'groups' => ['page:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category', 'translationChildren'],
],
denormalizationContext: [
'groups' => ['page:write'],
'swagger_definition_name' => 'Write',
]
)]
/**
* @use TranslationEntityTrait<Page>
* @use VariantEntityTrait<Page>
*/
class Page extends FormEntity implements TranslationEntityInterface, VariantEntityInterface, UuidInterface
{
use TranslationEntityTrait;
use VariantEntityTrait;
use UuidTrait;
use ProjectTrait;
public const ENTITY_NAME = 'page';
public const TABLE_NAME = 'pages';
/**
* @var int
*/
#[Groups(['page:read', 'download:read', 'email:read'])]
private $id;
/**
* @var string
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $title;
/**
* @var string
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $alias;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $template;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $customHtml;
/**
* @var array
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $content = [];
/**
* @var \DateTimeInterface|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $publishUp;
/**
* @var \DateTimeInterface|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $publishDown;
/**
* @var int
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $hits = 0;
/**
* @var int
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $uniqueHits = 0;
/**
* @var int
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $variantHits = 0;
/**
* @var int
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $revision = 1;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $metaDescription;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $headScript;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $footerScript;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $redirectType;
/**
* @var string|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $redirectUrl;
/**
* @var Category|null
**/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $category;
/**
* @var bool|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $isPreferenceCenter;
/**
* @var bool|null
*/
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private $noIndex;
/**
* Used to identify the page for the builder.
*/
private $sessionId;
private ?PageDraft $draft = null;
private bool $isCloned = false;
private ?int $cloneObjectId = null;
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private ?bool $publicPreview = true;
#[Groups(['page:read', 'page:write', 'download:read', 'email:read'])]
private bool $isDuplicate = false;
public function __clone()
{
$this->cloneObjectId = (int) $this->id;
$this->isCloned = true;
$this->id = null;
$this->clearTranslations();
$this->clearVariants();
$this->setDraft(null);
parent::__clone();
}
public function __construct()
{
$this->translationChildren = new \Doctrine\Common\Collections\ArrayCollection();
$this->variantChildren = new \Doctrine\Common\Collections\ArrayCollection();
$this->initializeProjects();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(PageRepository::class)
->addIndex(['alias'], 'page_alias_search');
$builder->addId();
$builder->addField('title', 'string');
$builder->addField('alias', 'string');
$builder->addNullableField('template', 'string');
$builder->createField('customHtml', 'text')
->columnName('custom_html')
->nullable()
->build();
$builder->createField('content', 'array')
->nullable()
->build();
$builder->addPublishDates();
$builder->addField('hits', 'integer');
$builder->createField('uniqueHits', 'integer')
->columnName('unique_hits')
->build();
$builder->createField('variantHits', 'integer')
->columnName('variant_hits')
->build();
$builder->addField('revision', 'integer');
$builder->createField('metaDescription', 'string')
->columnName('meta_description')
->nullable()
->build();
$builder->createField('headScript', 'text')
->columnName('head_script')
->nullable()
->build();
$builder->createField('footerScript', 'text')
->columnName('footer_script')
->nullable()
->build();
$builder->createField('redirectType', 'string')
->columnName('redirect_type')
->nullable()
->length(100)
->build();
$builder->createField('redirectUrl', 'string')
->columnName('redirect_url')
->nullable()
->length(2048)
->build();
$builder->addCategory();
$builder->createField('isPreferenceCenter', 'boolean')
->columnName('is_preference_center')
->nullable()
->build();
$builder->createField('noIndex', 'boolean')
->columnName('no_index')
->nullable()
->build();
$builder->createOneToOne('draft', PageDraft::class)
->mappedBy('page')
->fetchExtraLazy()
->cascadeAll()
->build();
$builder->addNullableField('publicPreview', Types::BOOLEAN, 'public_preview');
self::addTranslationMetadata($builder, self::class);
self::addVariantMetadata($builder, self::class);
static::addUuidField($builder);
self::addProjectsField($builder, 'page_projects_xref', 'page_id');
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('title', new NotBlank([
'message' => 'mautic.core.title.required',
]));
$metadata->addConstraint(new Callback(
function (Page $page, ExecutionContextInterface $context): void {
$type = $page->getRedirectType();
if (!is_null($type)) {
$validator = $context->getValidator();
$violations = $validator->validate(
$page->getRedirectUrl(),
[
new Assert\Url(),
new NotBlank(['message' => 'mautic.core.value.required']),
],
);
foreach ($violations as $violation) {
$context->buildViolation($violation->getMessage())
->atPath('redirectUrl')
->addViolation();
}
}
if ($page->isVariant()) {
// Get a summation of weights
$parent = $page->getVariantParent();
$children = $parent ? $parent->getVariantChildren() : $page->getVariantChildren();
$total = 0;
foreach ($children as $child) {
$settings = $child->getVariantSettings();
$total += (int) $settings['weight'];
}
if ($total > 100) {
$context->buildViolation('mautic.core.variant_weights_invalid')
->atPath('variantSettings[weight]')
->addViolation();
}
}
},
));
$metadata->addConstraint(new EntityEvent());
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('page')
->addListProperties(
[
'id',
'title',
'alias',
'category',
]
)
->addProperties(
[
'language',
'publishUp',
'publishDown',
'hits',
'uniqueHits',
'variantHits',
'revision',
'metaDescription',
'redirectType',
'redirectUrl',
'isPreferenceCenter',
'noIndex',
'variantSettings',
'variantStartDate',
'variantParent',
'variantChildren',
'translationParent',
'translationChildren',
'template',
'customHtml',
]
)
->setMaxDepth(1, 'variantParent')
->setMaxDepth(1, 'variantChildren')
->setMaxDepth(1, 'translationParent')
->setMaxDepth(1, 'translationChildren')
->build();
self::addProjectsInLoadApiMetadata($metadata, 'page');
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set title.
*
* @param string $title
*
* @return Page
*/
public function setTitle($title)
{
$this->isChanged('title', $title);
$this->title = $title;
return $this;
}
/**
* Get title.
*
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* Set alias.
*
* @param string $alias
*
* @return Page
*/
public function setAlias($alias)
{
$this->isChanged('alias', $alias);
$this->alias = $alias;
return $this;
}
/**
* Get alias.
*
* @return string
*/
public function getAlias()
{
return $this->alias;
}
/**
* Set content.
*
* @param array<string> $content
*
* @return Page
*/
public function setContent($content)
{
$this->isChanged('content', $content);
$this->content = $content;
return $this;
}
/**
* Get content.
*
* @return array<string>
*/
public function getContent()
{
return $this->content;
}
/**
* Set publishUp.
*
* @param \DateTime $publishUp
*
* @return Page
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* Get publishUp.
*
* @return \DateTimeInterface
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* Set publishDown.
*
* @param \DateTime $publishDown
*
* @return Page
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* Get publishDown.
*
* @return \DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* Set hits.
*
* @param int $hits
*
* @return Page
*/
public function setHits($hits)
{
$this->hits = $hits;
return $this;
}
/**
* Get hits.
*
* @param bool $includeVariants
*
* @return int|mixed
*/
public function getHits($includeVariants = false)
{
return ($includeVariants) ? $this->getAccumulativeVariantCount('getHits') : $this->hits;
}
/**
* Set revision.
*
* @param int $revision
*
* @return Page
*/
public function setRevision($revision)
{
$this->revision = $revision;
return $this;
}
/**
* Get revision.
*
* @return int
*/
public function getRevision()
{
return $this->revision;
}
/**
* Set metaDescription.
*
* @param string $metaDescription
*
* @return Page
*/
public function setMetaDescription($metaDescription)
{
$this->isChanged('metaDescription', $metaDescription);
$this->metaDescription = $metaDescription;
return $this;
}
/**
* Get metaDescription.
*
* @return string
*/
public function getMetaDescription()
{
return $this->metaDescription;
}
/**
* Set headScript.
*
* @param string $headScript
*
* @return Page
*/
public function setHeadScript($headScript)
{
$this->headScript = $headScript;
return $this;
}
/**
* Get headScript.
*
* @return string
*/
public function getHeadScript()
{
return $this->headScript;
}
/**
* Set footerScript.
*
* @param string $footerScript
*
* @return Page
*/
public function setFooterScript($footerScript)
{
$this->footerScript = $footerScript;
return $this;
}
/**
* Get footerScript.
*
* @return string
*/
public function getFooterScript()
{
return $this->footerScript;
}
/**
* @param ?string $redirectType
*
* @return Page
*/
public function setRedirectType($redirectType)
{
$this->isChanged('redirectType', $redirectType);
$this->redirectType = $redirectType;
return $this;
}
/**
* @return ?string
*/
public function getRedirectType()
{
return $this->redirectType;
}
/**
* Set redirectUrl.
*
* @param string $redirectUrl
*
* @return Page
*/
public function setRedirectUrl($redirectUrl)
{
$this->isChanged('redirectUrl', $redirectUrl);
$this->redirectUrl = $redirectUrl;
return $this;
}
/**
* Get redirectUrl.
*
* @return string
*/
public function getRedirectUrl()
{
return $this->redirectUrl;
}
/**
* Set category.
*
* @return Page
*/
public function setCategory(?Category $category = null)
{
$this->isChanged('category', $category);
$this->category = $category;
return $this;
}
/**
* Get category.
*
* @return Category
*/
public function getCategory()
{
return $this->category;
}
/**
* @param bool|null $isPreferenceCenter
*
* @return Page
*/
public function setIsPreferenceCenter($isPreferenceCenter)
{
$sanitizedValue = null === $isPreferenceCenter ? null : (bool) $isPreferenceCenter;
$this->isChanged('isPreferenceCenter', $sanitizedValue);
$this->isPreferenceCenter = $sanitizedValue;
return $this;
}
/**
* @return bool|null
*/
public function getIsPreferenceCenter()
{
return $this->isPreferenceCenter;
}
/**
* @param bool|null $noIndex
*/
public function setNoIndex($noIndex): void
{
$sanitizedValue = null === $noIndex ? null : (bool) $noIndex;
$this->isChanged('noIndex', $sanitizedValue);
$this->noIndex = $sanitizedValue;
}
/**
* @return bool|null
*/
public function getNoIndex()
{
return $this->noIndex;
}
/**
* Set sessionId.
*
* @param string $id
*
* @return Page
*/
public function setSessionId($id)
{
$this->sessionId = $id;
return $this;
}
/**
* Get sessionId.
*
* @return string
*/
public function getSessionId()
{
return $this->sessionId;
}
/**
* Set template.
*
* @param string $template
*
* @return Page
*/
public function setTemplate($template)
{
$this->isChanged('template', $template);
$this->template = $template;
return $this;
}
/**
* Get template.
*
* @return string
*/
public function getTemplate()
{
return $this->template;
}
protected function isChanged($prop, $val)
{
$getter = 'get'.ucfirst($prop);
$current = $this->$getter();
if ('translationParent' == $prop || 'variantParent' == $prop || 'category' == $prop) {
$currentId = ($current) ? $current->getId() : '';
$newId = ($val) ? $val->getId() : null;
if ($currentId != $newId) {
$this->changes[$prop] = [$currentId, $newId];
}
} else {
parent::isChanged($prop, $val);
}
}
/**
* Set uniqueHits.
*
* @param int $uniqueHits
*
* @return Page
*/
public function setUniqueHits($uniqueHits)
{
$this->uniqueHits = $uniqueHits;
return $this;
}
/**
* Get uniqueHits.
*
* @return int
*/
public function getUniqueHits($includeVariants = false)
{
return ($includeVariants) ? $this->getAccumulativeVariantCount('getUniqueHits') : $this->uniqueHits;
}
/**
* @param bool $includeVariants
*
* @return int|mixed
*/
public function getVariantHits($includeVariants = false)
{
return ($includeVariants) ? $this->getAccumulativeVariantCount('getVariantHits') : $this->variantHits;
}
/**
* @param mixed $variantHits
*/
public function setVariantHits($variantHits): void
{
$this->variantHits = $variantHits;
}
/**
* @return mixed
*/
public function getCustomHtml()
{
return $this->customHtml;
}
/**
* @param mixed $customHtml
*/
public function setCustomHtml($customHtml): void
{
$this->customHtml = $customHtml;
}
public function hasDraft(): bool
{
return !is_null($this->getDraft());
}
public function getDraftContent(): ?string
{
return $this->hasDraft() ? $this->getDraft()->getHtml() : null;
}
public function getDraft(): ?PageDraft
{
return $this->draft;
}
public function setDraft(?PageDraft $draft): void
{
$this->draft = $draft;
}
public function getIsClone(): bool
{
return $this->isCloned;
}
public function getCloneObjectId(): int
{
return $this->cloneObjectId;
}
public function getPublicPreview(): bool
{
return $this->publicPreview;
}
public function isPublicPreview(): bool
{
return $this->publicPreview;
}
public function setPublicPreview(bool $publicPreview): self
{
$this->isChanged('publicPreview', $publicPreview);
$this->publicPreview = $publicPreview;
return $this;
}
public function isDuplicate(): bool
{
return $this->isDuplicate;
}
public function setIsDuplicate(bool $isDuplicate): void
{
$this->isDuplicate = $isDuplicate;
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class PageDraft
{
/**
* @var string
*/
public const TABLE_NAME = 'pages_draft';
/**
* @var string
*/
public const REGEX_DECODE_AMPERSAND = '/((https?|ftps?):\/\/)([a-zA-Z0-9-\.{}]*[a-zA-Z0-9=}]*)(\??)([^\s\"\]]+)?/i';
private ?int $id = null;
public function __construct(
private Page $page,
private ?string $html = null,
private ?string $template = null,
private bool $publicPreview = true,
) {
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(PageDraftRepository::class)
->addLifecycleEvent('cleanUrlsInContent', Events::preUpdate)
->addLifecycleEvent('cleanUrlsInContent', Events::prePersist);
$builder->addId();
$builder->addNullableField('html', Types::TEXT);
$builder->addNullableField('template', Types::STRING);
$builder->createField('publicPreview', Types::BOOLEAN)
->columnName('public_preview')
->nullable(false)
->option('default', 1)
->build();
$builder->createOneToOne('page', Page::class)
->inversedBy('draft')
->addJoinColumn('page_id', 'id', false)
->build();
}
/**
* Lifecycle callback to clean URLs in the content.
*/
public function cleanUrlsInContent(): void
{
$this->html = $this->decodeAmpersands((string) $this->html);
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): void
{
$this->id = $id;
}
public function getPage(): Page
{
return $this->page;
}
public function getHtml(): ?string
{
return $this->html;
}
public function setPage(Page $page): void
{
$this->page = $page;
}
public function setHtml(?string $html): void
{
$this->html = $html;
}
public function getTemplate(): ?string
{
return $this->template;
}
public function setTemplate(?string $template): void
{
$this->template = $template;
}
public function isPublicPreview(): bool
{
return (bool) $this->publicPreview;
}
public function setPublicPreview(bool $publicPreview): void
{
$this->publicPreview = $publicPreview;
}
/**
* Check all links in content and decode &amp;
* This even works with double encoded ampersands.
*/
private function decodeAmpersands(string $content): string
{
if (!preg_match_all(self::REGEX_DECODE_AMPERSAND, $content, $matches)) {
return $content;
}
foreach ($matches[0] as $url) {
$newUrl = $url;
while (str_contains($newUrl, '&amp;')) {
$newUrl = str_replace('&amp;', '&', $newUrl);
}
$content = str_replace($url, $newUrl, $content);
}
return $content;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
class PageDraftRepository extends CommonRepository
{
}

View File

@@ -0,0 +1,275 @@
<?php
namespace Mautic\PageBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Page>
*/
class PageRepository extends CommonRepository
{
use ProjectRepositoryTrait;
public function getEntities(array $args = [])
{
$select = ['p'];
if (!empty($args['submissionCount'])) {
// use a subquery to get a count of submissions otherwise doctrine will not pull all of the results
$sq = $this->_em->createQueryBuilder()
->select('count(fs.id)')
->from(\Mautic\FormBundle\Entity\Submission::class, 'fs')
->where('fs.page = p');
$select[] = '('.$sq->getDql().') as submission_count';
}
$q = $this->createQueryBuilder('p')
->select($select)
->leftJoin('p.category', 'c');
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* @param string $alias
*
* @return mixed
*/
public function checkPageUniqueAlias($alias, $ignoreIds = [])
{
$q = $this->createQueryBuilder('e')
->select('count(e.id) as alias_count')
->where('e.alias = :alias');
$q->setParameter('alias', $alias);
if (!empty($ignoreIds)) {
$q->andWhere(
$q->expr()->notIn('e.id', ':ignoreIds')
)
->setParameter('ignoreIds', $ignoreIds);
}
$results = $q->getQuery()->getSingleResult();
return $results['alias_count'];
}
/**
* @param string $search
* @param int $limit
* @param int $start
* @param bool $viewOther
* @param string|bool $topLevel
* @param array $ignoreIds
* @param array $extraColumns
* @param bool $publishedOnly
*
* @return array
*/
public function getPageList($search = '', $limit = 10, $start = 0, $viewOther = false, $topLevel = false, $ignoreIds = [], $extraColumns = [], $publishedOnly = false)
{
$q = $this->createQueryBuilder('p');
$q->select(sprintf('partial p.{id, title, language, alias %s}', empty($extraColumns) ? '' : ','.implode(',', $extraColumns)));
if (!empty($search)) {
$q->andWhere($q->expr()->like('p.title', ':search'))
->setParameter('search', "{$search}%");
}
if (!$viewOther) {
$q->andWhere($q->expr()->eq('p.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
if ('translation' == $topLevel) {
// only get top level pages
$q->andWhere($q->expr()->isNull('p.translationParent'));
} elseif ('variant' == $topLevel) {
$q->andWhere($q->expr()->isNull('p.variantParent'));
}
if (!empty($ignoreIds)) {
$q->andWhere($q->expr()->notIn('p.id', ':pageIds'))
->setParameter('pageIds', $ignoreIds);
}
if ($publishedOnly) {
$q->andWhere($q->expr()->eq('p.isPublished', 1));
}
$q->orderBy('p.title');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause(
$q,
$filter,
[
'p.title',
'p.alias',
]
);
}
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
switch ($command) {
case $this->translator->trans('mautic.page.searchcommand.isexpired'):
case $this->translator->trans('mautic.page.searchcommand.isexpired', [], null, 'en_US'):
$expr = sprintf(
"(p.isPublished = :%1\$s AND p.publishDown IS NOT NULL AND p.publishDown <> '' AND p.publishDown < CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.page.searchcommand.ispending'):
case $this->translator->trans('mautic.page.searchcommand.ispending', [], null, 'en_US'):
$expr = sprintf(
"(p.isPublished = :%1\$s AND p.publishUp IS NOT NULL AND p.publishUp <> '' AND p.publishUp > CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.core.searchcommand.lang'):
case $this->translator->trans('mautic.core.searchcommand.lang', [], null, 'en_US'):
$langUnique = $this->generateRandomParameterName();
$langValue = $filter->string.'_%';
$forceParameters = [
$langUnique => $langValue,
$unique => $filter->string,
];
$expr = '('.$q->expr()->eq('p.language', ":$unique").' OR '.$q->expr()->like('p.language', ":$langUnique").')';
$returnParameter = true;
break;
case $this->translator->trans('mautic.page.searchcommand.isprefcenter'):
case $this->translator->trans('mautic.page.searchcommand.isprefcenter', [], null, 'en_US'):
$expr = $q->expr()->eq('p.isPreferenceCenter', ":$unique");
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.project.searchcommand.name'):
case $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US'):
return $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'page_id',
'page_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.isuncategorized',
'mautic.core.searchcommand.ismine',
'mautic.page.searchcommand.isexpired',
'mautic.page.searchcommand.ispending',
'mautic.core.searchcommand.category',
'mautic.core.searchcommand.lang',
'mautic.page.searchcommand.isprefcenter',
'mautic.project.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
protected function getDefaultOrder(): array
{
return [
['p.title', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'p';
}
/**
* Resets variant_start_date and variant_hits.
*/
public function resetVariants($relatedIds, $date): void
{
if (!is_array($relatedIds)) {
$relatedIds = [(int) $relatedIds];
}
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.'pages')
->set('variant_hits', 0)
->set('variant_start_date', ':date')
->setParameter('date', $date)
->where(
$qb->expr()->in('id', $relatedIds)
)
->executeStatement();
}
/**
* Up the hit count.
*
* @param int $increaseBy
* @param bool|false $unique
* @param bool|false $variant
*/
public function upHitCount($id, $increaseBy = 1, $unique = false, $variant = false): void
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'pages')
->set('hits', 'hits + '.(int) $increaseBy)
->where('id = '.(int) $id);
if ($unique) {
$q->set('unique_hits', 'unique_hits + '.(int) $increaseBy);
}
if ($variant) {
$q->set('variant_hits', 'variant_hits + '.(int) $increaseBy);
}
$q->executeStatement();
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Mautic\PageBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
class Redirect extends FormEntity
{
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $redirectId;
private $url;
/**
* @var int
*/
private $hits = 0;
/**
* @var int
*/
private $uniqueHits = 0;
/**
* @var ArrayCollection<int, Trackable>
*/
private $trackables;
public function __construct()
{
$this->trackables = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('page_redirects')
->setCustomRepositoryClass(RedirectRepository::class);
$builder->addBigIntIdField();
$builder->createField('redirectId', 'string')
->columnName('redirect_id')
->length(25)
->build();
$builder->addField('url', 'text');
$builder->addField('hits', 'integer');
$builder->createField('uniqueHits', 'integer')
->columnName('unique_hits')
->build();
$builder->createOneToMany('trackables', 'Trackable')
->mappedBy('redirect')
->fetchExtraLazy()
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('redirect')
->addListProperties(
[
'id',
'redirectId',
'url',
]
)
->addProperties(
[
'hits',
'uniqueHits',
]
)
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @return string
*/
public function getRedirectId()
{
return $this->redirectId;
}
/**
* @param string $redirectId
*/
public function setRedirectId($redirectId = null): void
{
if (null === $redirectId) {
$redirectId = substr(hash('sha1', uniqid(mt_rand())), 0, 25);
}
$this->redirectId = $redirectId;
}
public function getUrl(): string
{
return trim($this->url);
}
/**
* @param string $url
*/
public function setUrl($url): void
{
$this->url = trim($url);
}
/**
* Set hits.
*
* @param int $hits
*/
public function setHits($hits): Redirect
{
$this->hits = $hits;
return $this;
}
/**
* Get hits.
*
* @return int
*/
public function getHits()
{
return $this->hits;
}
/**
* Set uniqueHits.
*
* @param int $uniqueHits
*/
public function setUniqueHits($uniqueHits): Redirect
{
$this->uniqueHits = $uniqueHits;
return $this;
}
/**
* Get uniqueHits.
*
* @return int
*/
public function getUniqueHits()
{
return $this->uniqueHits;
}
/**
* @return ArrayCollection
*/
public function getTrackableList()
{
return $this->trackables;
}
/**
* @param ArrayCollection $trackables
*
* @return Redirect
*/
public function setTrackables($trackables)
{
$this->trackables = $trackables;
return $this;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Mautic\PageBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Redirect>
*/
class RedirectRepository extends CommonRepository
{
/**
* @return array
*/
public function findByUrls(array $urls)
{
$q = $this->createQueryBuilder('r');
$expr = $q->expr()->andX(
$q->expr()->in('r.url', ':urls')
);
$q->where($expr)
->setParameter('urls', $urls);
return $q->getQuery()->getResult();
}
/**
* Up the hit count.
*
* @param int $increaseBy
* @param bool|false $unique
*/
public function upHitCount($id, $increaseBy = 1, $unique = false): void
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'page_redirects')
->set('hits', 'hits + '.(int) $increaseBy)
->where('id = '.(int) $id);
if ($unique) {
$q->set('unique_hits', 'unique_hits + '.(int) $increaseBy);
}
$q->executeStatement();
}
/**
* @param int $limit
* @param int|null $createdByUserId
* @param int|null $companyId
* @param int|null $campaignId
* @param int|null $segmentId
*/
public function getMostHitEmailRedirects(
$limit,
\DateTime $dateFrom,
\DateTime $dateTo,
$createdByUserId = null,
$companyId = null,
$campaignId = null,
$segmentId = null,
): array {
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->addSelect('pr.url')
->addSelect('count(ph.id) as hits')
->addSelect('count(distinct ph.tracking_id) as unique_hits')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->join('ph', MAUTIC_TABLE_PREFIX.'page_redirects', 'pr', 'pr.id = ph.redirect_id')
->join('ph', MAUTIC_TABLE_PREFIX.'email_stats', 'es', 'ph.source = \'email\' and ph.source_id = es.email_id and ph.lead_id = es.lead_id')
->join('es', MAUTIC_TABLE_PREFIX.'emails', 'e', 'es.email_id = e.id')
->addSelect('e.id AS email_id')
->addSelect('e.name AS email_name');
if (null !== $createdByUserId) {
$q->andWhere('e.created_by = :userId')
->setParameter('userId', $createdByUserId);
}
$q->andWhere('ph.date_hit BETWEEN :dateFrom AND :dateTo')
->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s'))
->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s'));
$q->leftJoin('es', MAUTIC_TABLE_PREFIX.'campaign_events', 'ce', 'es.source = "campaign.event" and es.source_id = ce.id')
->leftJoin('ce', MAUTIC_TABLE_PREFIX.'campaigns', 'campaign', 'ce.campaign_id = campaign.id')
->addSelect('campaign.id AS campaign_id')
->addSelect('campaign.name AS campaign_name');
if (null !== $campaignId) {
$q->andWhere('ce.campaign_id = :campaignId')
->setParameter('campaignId', $campaignId);
}
if (!empty($companyId)) {
$sb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$sb->select('null')
->from(MAUTIC_TABLE_PREFIX.'companies_leads', 'cl')
->where(
$sb->expr()->and(
$sb->expr()->eq('cl.company_id', ':companyId'),
$sb->expr()->eq('cl.lead_id', 'ph.lead_id')
)
);
$q->andWhere(
sprintf('EXISTS (%s)', $sb->getSQL())
)
->setParameter('companyId', $companyId);
}
if (null !== $segmentId) {
$sb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$sb->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll')
->where(
$sb->expr()->and(
$sb->expr()->eq('lll.leadlist_id', ':segmentId'),
$sb->expr()->eq('lll.lead_id', 'ph.lead_id'),
$sb->expr()->eq('lll.manually_removed', 0)
)
);
$q->andWhere(
sprintf('EXISTS (%s)', $sb->getSQL())
)
->setParameter('segmentId', $segmentId);
}
$q->groupBy('pr.id, pr.url, e.id, e.name, campaign.id, campaign.name');
$q->setMaxResults($limit);
$q->orderBy('hits', 'DESC');
return $q->executeQuery()->fetchAllAssociative();
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace Mautic\PageBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class Trackable
{
/**
* @var Redirect
*/
private $redirect;
/**
* @var string
*/
private $channel;
/**
* @var int
*/
private $channelId;
/**
* @var int
*/
private $hits = 0;
/**
* @var int
*/
private $uniqueHits = 0;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('channel_url_trackables')
->setCustomRepositoryClass(TrackableRepository::class)
->addIndex(['channel', 'channel_id'], 'channel_url_trackable_search');
$builder->createManyToOne('redirect', Redirect::class)
->addJoinColumn('redirect_id', 'id', true, false, 'CASCADE')
->cascadePersist()
->inversedBy('trackables')
->isPrimaryKey()
->build();
$builder->createField('channelId', 'integer')
->columnName('channel_id')
->makePrimaryKey()
->build();
$builder->addField('channel', 'string');
$builder->addField('hits', 'integer');
$builder->addNamedField('uniqueHits', 'integer', 'unique_hits');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('trackable')
->addListProperties(
[
'redirect',
'channelId',
'channel',
'hits',
'uniqueHits',
]
)
->build();
}
/**
* @return Redirect
*/
public function getRedirect()
{
return $this->redirect;
}
/**
* @return Trackable
*/
public function setRedirect(Redirect $redirect)
{
$this->redirect = $redirect;
return $this;
}
/**
* @return string
*/
public function getChannel()
{
return $this->channel;
}
/**
* @param string $channel
*
* @return Trackable
*/
public function setChannel($channel)
{
$this->channel = $channel;
return $this;
}
/**
* @return int
*/
public function getChannelId()
{
return $this->channelId;
}
/**
* @param int $channelId
*
* @return Trackable
*/
public function setChannelId($channelId)
{
$this->channelId = $channelId;
return $this;
}
/**
* @return int
*/
public function getHits()
{
return $this->hits;
}
/**
* @param int $hits
*
* @return Trackable
*/
public function setHits($hits)
{
$this->hits = $hits;
return $this;
}
/**
* @return int
*/
public function getUniqueHits()
{
return $this->uniqueHits;
}
/**
* @param int $uniqueHits
*
* @return Trackable
*/
public function setUniqueHits($uniqueHits)
{
$this->uniqueHits = $uniqueHits;
return $this;
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace Mautic\PageBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
/**
* @extends CommonRepository<Trackable>
*/
class TrackableRepository extends CommonRepository
{
/**
* Find redirects that are trackable.
*
* @return mixed[]
*/
public function findByChannel($channel, $channelId): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$tableAlias = $this->getTableAlias();
return $q->select('r.redirect_id, r.url, r.id, '.$tableAlias.'.hits, '.$tableAlias.'.unique_hits')
->from(MAUTIC_TABLE_PREFIX.'page_redirects', 'r')
->innerJoin('r', MAUTIC_TABLE_PREFIX.'channel_url_trackables', $tableAlias,
$q->expr()->and(
$q->expr()->eq('r.id', 't.redirect_id'),
$q->expr()->eq('t.channel', ':channel'),
$q->expr()->eq('t.channel_id', (int) $channelId)
)
)
->setParameter('channel', $channel)
->orderBy('r.url')
->executeQuery()
->fetchAllAssociative();
}
/**
* Get a Trackable by Redirect URL.
*
* @return array
*/
public function findByUrl($url, $channel, $channelId)
{
$alias = $this->getTableAlias();
$q = $this->createQueryBuilder($alias)
->innerJoin("$alias.redirect", 'r');
$q->where(
$q->expr()->andX(
$q->expr()->eq("$alias.channel", ':channel'),
$q->expr()->eq("$alias.channelId", (int) $channelId),
$q->expr()->eq('r.url', ':url')
)
)
->setParameter('url', $url)
->setParameter('channel', $channel);
$result = $q->getQuery()->getResult();
return ($result) ? $result[0] : null;
}
/**
* Get an array of Trackable entities by Redirect URLs.
*
* @return array
*/
public function findByUrls(array $urls, $channel, $channelId)
{
$alias = $this->getTableAlias();
$q = $this->createQueryBuilder($alias)
->innerJoin("$alias.redirect", 'r');
$q->where(
$q->expr()->andX(
$q->expr()->eq("$alias.channel", ':channel'),
$q->expr()->eq("$alias.channelId", (int) $channelId),
$q->expr()->in('r.url', ':urls')
)
)
->setParameter('urls', $urls)
->setParameter('channel', $channel);
return $q->getQuery()->getResult();
}
/**
* Up the hit count.
*
* @param int $increaseBy
* @param bool $unique
*/
public function upHitCount($redirectId, $channel, $channelId, $increaseBy = 1, $unique = false): void
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'channel_url_trackables')
->set('hits', 'hits + '.(int) $increaseBy)
->where(
$q->expr()->and(
$q->expr()->eq('redirect_id', (int) $redirectId),
$q->expr()->eq('channel', ':channel'),
$q->expr()->eq('channel_id', (int) $channelId)
)
)
->setParameter('channel', $channel);
if ($unique) {
$q->set('unique_hits', 'unique_hits + '.(int) $increaseBy);
}
$q->executeStatement();
}
/**
* Get hit count.
*
* @param bool $combined
* @param string $countColumn
*
* @return array|int
*/
public function getCount($channel, $channelIds, $listId, ?ChartQuery $chartQuery = null, $combined = false, $countColumn = 'ph.id')
{
$q = $this->_em->getConnection()->createQueryBuilder()
->select('count('.$countColumn.') as click_count')
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', 'cut')
->innerJoin('cut', MAUTIC_TABLE_PREFIX.'page_hits', 'ph', 'ph.redirect_id = cut.redirect_id AND ph.source = cut.channel AND ph.source_id = cut.channel_id');
$q->where(
'cut.channel = :channel'
)->setParameter('channel', $channel);
if ($channelIds) {
if (!is_array($channelIds)) {
$channelIds = [(int) $channelIds];
}
$q->andWhere(
$q->expr()->in('cut.channel_id', $channelIds)
);
}
if ($listId) {
if (!$combined) {
$q->innerJoin('ph', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'cs', 'cs.lead_id = ph.lead_id');
if (true === $listId) {
$q->addSelect('cs.leadlist_id')
->groupBy('cs.leadlist_id');
} elseif (is_array($listId)) {
$q->andWhere(
$q->expr()->in('cs.leadlist_id', array_map('intval', $listId))
);
$q->addSelect('cs.leadlist_id')
->groupBy('cs.leadlist_id');
} else {
$q->andWhere('cs.leadlist_id = :list_id')
->setParameter('list_id', $listId);
}
} else {
$subQ = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQ->select('distinct(list.lead_id)')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'list')
->andWhere(
$q->expr()->in('list.leadlist_id', array_map('intval', $listId))
);
$q->innerJoin('ph', sprintf('(%s)', $subQ->getSQL()), 'cs', 'cs.lead_id = ph.lead_id');
}
}
if ($chartQuery) {
$chartQuery->applyDateFilters($q, 'date_hit', 'ph');
}
$results = $q->executeQuery()->fetchAllAssociative();
if ((true === $listId || is_array($listId)) && !$combined) {
// Return array of results
$byList = [];
foreach ($results as $result) {
$byList[$result['leadlist_id']] = $result['click_count'];
}
return $byList;
}
return (isset($results[0])) ? $results[0]['click_count'] : 0;
}
public function getTableAlias(): string
{
return 't';
}
}

View File

@@ -0,0 +1,776 @@
<?php
namespace Mautic\PageBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\LeadBundle\Entity\Lead;
class VideoHit
{
public const TABLE_NAME = 'video_hits';
/**
* @var int
*/
private $id;
/**
* @var string
*/
private $guid;
/**
* @var \DateTimeInterface
*/
private $dateHit;
/**
* @var \DateTimeInterface
*/
private $dateLeft;
/**
* @var int|null
*/
private $timeWatched;
/**
* @var int|null
*/
private $duration;
/**
* @var Redirect
*/
private $redirect;
/**
* @var Lead|null
*/
private $lead;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var string|null
*/
private $country;
/**
* @var string|null
*/
private $region;
/**
* @var string|null
*/
private $city;
/**
* @var string|null
*/
private $isp;
/**
* @var string|null
*/
private $organization;
/**
* @var int
*/
private $code;
private $referer;
private $url;
/**
* @var string|null
*/
private $userAgent;
/**
* @var string|null
*/
private $remoteHost;
/**
* @var string|null
*/
private $pageLanguage;
/**
* @var array<string>
*/
private $browserLanguages = [];
/**
* @var string|null
*/
private $channel;
/**
* @var int|null
*/
private $channelId;
/**
* @var array
*/
private $query = [];
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(VideoHitRepository::class)
->addIndex(['date_hit'], 'video_date_hit')
->addIndex(['channel', 'channel_id'], 'video_channel_search')
->addIndex(['guid', 'lead_id'], 'video_guid_lead_search');
$builder->addId();
$builder->createField('dateHit', 'datetime')
->columnName('date_hit')
->build();
$builder->createField('dateLeft', 'datetime')
->columnName('date_left')
->nullable()
->build();
$builder->addLead(true, 'SET NULL');
$builder->addIpAddress(true);
$builder->createField('country', 'string')
->nullable()
->build();
$builder->createField('region', 'string')
->nullable()
->build();
$builder->createField('city', 'string')
->nullable()
->build();
$builder->createField('isp', 'string')
->nullable()
->build();
$builder->createField('organization', 'string')
->nullable()
->build();
$builder->addField('code', 'integer');
$builder->createField('referer', 'text')
->nullable()
->build();
$builder->createField('url', 'text')
->nullable()
->build();
$builder->createField('userAgent', 'text')
->columnName('user_agent')
->nullable()
->build();
$builder->createField('remoteHost', 'string')
->columnName('remote_host')
->nullable()
->build();
$builder->createField('guid', 'string')
->columnName('guid')
->build();
$builder->createField('pageLanguage', 'string')
->columnName('page_language')
->nullable()
->build();
$builder->createField('browserLanguages', 'array')
->columnName('browser_languages')
->nullable()
->build();
$builder->createField('channel', 'string')
->nullable()
->build();
$builder->createField('channelId', 'integer')
->columnName('channel_id')
->nullable()
->build();
$builder->createField('timeWatched', 'integer')
->columnName('time_watched')
->nullable()
->build();
$builder->createField('duration', 'integer')
->columnName('duration')
->nullable()
->build();
$builder->addNullableField('query', 'array');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('hit')
->addProperties(
[
'dateHit',
'dateLeft',
'lead',
'ipAddress',
'country',
'region',
'city',
'isp',
'code',
'referer',
'url',
'urlTitle',
'userAgent',
'remoteHost',
'pageLanguage',
'browserLanguages',
'source',
'sourceId',
'query',
'timeWatched',
'guid',
]
)
->build();
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set dateHit.
*
* @param \DateTime $dateHit
*
* @return VideoHit
*/
public function setDateHit($dateHit)
{
$this->dateHit = $dateHit;
return $this;
}
/**
* Get dateHit.
*
* @return \DateTimeInterface
*/
public function getDateHit()
{
return $this->dateHit;
}
/**
* @return \DateTimeInterface
*/
public function getDateLeft()
{
return $this->dateLeft;
}
/**
* @param \DateTime $dateLeft
*
* @return VideoHit
*/
public function setDateLeft($dateLeft)
{
$this->dateLeft = $dateLeft;
return $this;
}
/**
* Set country.
*
* @param string $country
*
* @return VideoHit
*/
public function setCountry($country)
{
$this->country = $country;
return $this;
}
/**
* Get country.
*
* @return string
*/
public function getCountry()
{
return $this->country;
}
/**
* Set region.
*
* @param string $region
*
* @return VideoHit
*/
public function setRegion($region)
{
$this->region = $region;
return $this;
}
/**
* Get region.
*
* @return string
*/
public function getRegion()
{
return $this->region;
}
/**
* Set city.
*
* @param string $city
*
* @return VideoHit
*/
public function setCity($city)
{
$this->city = $city;
return $this;
}
/**
* Get city.
*
* @return string
*/
public function getCity()
{
return $this->city;
}
/**
* Set isp.
*
* @param string $isp
*
* @return VideoHit
*/
public function setIsp($isp)
{
$this->isp = $isp;
return $this;
}
/**
* Get isp.
*
* @return string
*/
public function getIsp()
{
return $this->isp;
}
/**
* Set organization.
*
* @param string $organization
*
* @return VideoHit
*/
public function setOrganization($organization)
{
$this->organization = $organization;
return $this;
}
/**
* Get organization.
*
* @return string
*/
public function getOrganization()
{
return $this->organization;
}
/**
* Set code.
*
* @param int $code
*
* @return VideoHit
*/
public function setCode($code)
{
$this->code = $code;
return $this;
}
/**
* Get code.
*
* @return int
*/
public function getCode()
{
return $this->code;
}
/**
* Set referer.
*
* @param string $referer
*
* @return VideoHit
*/
public function setReferer($referer)
{
$this->referer = $referer;
return $this;
}
/**
* Get referer.
*
* @return string
*/
public function getReferer()
{
return $this->referer;
}
/**
* Set url.
*
* @param string $url
*
* @return VideoHit
*/
public function setUrl($url)
{
$this->url = $url;
return $this;
}
/**
* Get url.
*
* @return string
*/
public function getUrl()
{
return $this->url;
}
/**
* Set userAgent.
*
* @param string $userAgent
*
* @return VideoHit
*/
public function setUserAgent($userAgent)
{
$this->userAgent = $userAgent;
return $this;
}
/**
* Get userAgent.
*
* @return string
*/
public function getUserAgent()
{
return $this->userAgent;
}
/**
* Set remoteHost.
*
* @param string $remoteHost
*
* @return VideoHit
*/
public function setRemoteHost($remoteHost)
{
$this->remoteHost = $remoteHost;
return $this;
}
/**
* Get remoteHost.
*
* @return string
*/
public function getRemoteHost()
{
return $this->remoteHost;
}
/**
* @return VideoHit
*/
public function setIpAddress(IpAddress $ipAddress)
{
$this->ipAddress = $ipAddress;
return $this;
}
/**
* @return IpAddress
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* Set pageLanguage.
*
* @param string $pageLanguage
*
* @return VideoHit
*/
public function setPageLanguage($pageLanguage)
{
$this->pageLanguage = $pageLanguage;
return $this;
}
/**
* Get pageLanguage.
*
* @return string
*/
public function getPageLanguage()
{
return $this->pageLanguage;
}
/**
* Set browserLanguages.
*
* @param array<string> $browserLanguages
*
* @return VideoHit
*/
public function setBrowserLanguages($browserLanguages)
{
$this->browserLanguages = $browserLanguages;
return $this;
}
/**
* Get browserLanguages.
*
* @return array<string>
*/
public function getBrowserLanguages()
{
return $this->browserLanguages;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @return VideoHit
*/
public function setLead(Lead $lead)
{
$this->lead = $lead;
return $this;
}
/**
* @return string
*/
public function getChannel()
{
return $this->channel;
}
/**
* @param string $channel
*
* @return VideoHit
*/
public function setChannel($channel)
{
$this->channel = $channel;
return $this;
}
/**
* @return int
*/
public function getChannelId()
{
return $this->channelId;
}
/**
* @param int $channelId
*
* @return VideoHit
*/
public function setChannelId($channelId)
{
$this->channelId = (int) $channelId;
return $this;
}
/**
* @return Redirect
*/
public function getRedirect()
{
return $this->redirect;
}
/**
* @return VideoHit
*/
public function setRedirect(Redirect $redirect)
{
$this->redirect = $redirect;
return $this;
}
/**
* @return array
*/
public function getQuery()
{
return $this->query;
}
/**
* @param array $query
*
* @return VideoHit
*/
public function setQuery($query)
{
$this->query = $query;
return $this;
}
/**
* @return int
*/
public function getTimeWatched()
{
return $this->timeWatched;
}
/**
* @return VideoHit
*/
public function setTimeWatched($timeWatched)
{
$this->timeWatched = $timeWatched;
return $this;
}
/**
* @return string
*/
public function getGuid()
{
return $this->guid;
}
/**
* @param string $guid
*
* @return VideoHit
*/
public function setGuid($guid)
{
$this->guid = $guid;
return $this;
}
/**
* @return int
*/
public function getDuration()
{
return $this->duration;
}
/**
* @param int $duration
*
* @return VideoHit
*/
public function setDuration($duration)
{
$this->duration = $duration;
return $this;
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Mautic\PageBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<VideoHit>
*/
class VideoHitRepository extends CommonRepository
{
use TimelineTrait;
/**
* Get video hit info for lead timeline.
*
* @param int|null $leadId
*
* @return array
*/
public function getTimelineStats($leadId = null, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->select('h.id, h.url, h.date_hit, h.time_watched, h.duration, h.referer, h.user_agent')
->from(MAUTIC_TABLE_PREFIX.'video_hits', 'h');
if ($leadId) {
$query->where($query->expr()->eq('h.lead_id', (int) $leadId));
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->like('h.url', ':search')
)->setParameter('search', '%'.$options['search'].'%');
}
return $this->getTimelineResults($query, $options, 'h.url', 'h.date_hit', [], ['date_hit'], null, 'h.id');
}
/**
* @param string $guid
*
* @return VideoHit
*/
public function getHitForLeadByGuid(Lead $lead, $guid)
{
$result = $this->findOneBy(['guid' => $guid, 'lead' => $lead]);
return $result ?: new VideoHit();
}
/**
* Get a lead's page hits.
*
* @param int $leadId
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getLeadHits($leadId, array $options = [])
{
$query = $this->createQueryBuilder('h');
$query->select('h.userAgent, h.dateHit, h.dateLeft, h.referer, h.channel, h.channelId, h.url, h.duration, h.query, h.timeWatched')
->where('h.lead = :leadId')
->setParameter('leadId', (int) $leadId);
if (isset($options['url']) && $options['url']) {
$query->andWhere($query->expr()->eq('h.url', $query->expr()->literal($options['url'])));
}
return $query->getQuery()->getArrayResult();
}
/**
* Count stats from hit times.
*
* @param array $times
*/
public function countStats($times): array
{
return [
'sum' => array_sum($times),
'min' => count($times) ? min($times) : 0,
'max' => count($times) ? max($times) : 0,
'average' => count($times) ? round(array_sum($times) / count($times)) : 0,
'count' => count($times),
];
}
/**
* Get list of referers ordered by it's count.
*
* @param \Doctrine\DBAL\Query\QueryBuilder $query
* @param int $limit
* @param int $offset
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getReferers($query, $limit = 10, $offset = 0): array
{
$query->select('h.referer, count(h.referer) as sessions')
->groupBy('h.referer')
->orderBy('sessions', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'video_hits')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\BuilderEvent;
use Mautic\PageBundle\Entity\Page;
class PageBuilderEvent extends BuilderEvent
{
/**
* @return Page|null
*/
public function getPage()
{
return $this->entity;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Page;
use Symfony\Contracts\EventDispatcher\Event;
class PageDisplayEvent extends Event
{
/**
* Preferred lead to be used in listeners.
*/
private ?Lead $lead = null;
public function __construct(
private string $content,
private Page $page,
private array $params = [],
) {
}
/**
* Returns the Page entity.
*
* @return Page
*/
public function getPage()
{
return $this->page;
}
/**
* Get page content.
*/
public function getContent(): string
{
return $this->content;
}
/**
* Set page content.
*
* @param string $content
*/
public function setContent($content): void
{
$this->content = $content;
}
/**
* Get params.
*
* @return array
*/
public function getParams()
{
return $this->params;
}
/**
* Set params.
*
* @param array $params
*/
public function setParams($params): void
{
$this->params = $params;
}
public function getLead(): ?Lead
{
return $this->lead;
}
public function setLead(Lead $lead): void
{
$this->lead = $lead;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PageBundle\Entity\Page;
final class PageEditSubmitEvent extends CommonEvent
{
public function __construct(
private Page $previousPage,
private Page $currentPage,
private bool $saveAndClose,
private bool $apply,
private bool $saveAsDraft,
private bool $applyDraft,
private bool $discardDraft,
) {
}
public function getPreviousPage(): Page
{
return $this->previousPage;
}
public function getCurrentPage(): Page
{
return $this->currentPage;
}
public function isSaveAndClose(): bool
{
return $this->saveAndClose;
}
public function isApply(): bool
{
return $this->apply;
}
public function isSaveAsDraft(): bool
{
return $this->saveAsDraft;
}
public function isApplyDraft(): bool
{
return $this->applyDraft;
}
public function isDiscardDraft(): bool
{
return $this->discardDraft;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PageBundle\Entity\Page;
class PageEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Page $page, $isNew = false)
{
$this->entity = $page;
$this->isNew = $isNew;
}
/**
* Returns the Page entity.
*
* @return Page
*/
public function getPage()
{
return $this->entity;
}
/**
* Sets the Page entity.
*/
public function setPage(Page $page): void
{
$this->entity = $page;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Page;
class PageHitEvent extends CommonEvent
{
protected ?Page $page = null;
/**
* @param mixed[] $clickthroughData
* @param bool $unique
*/
public function __construct(
Hit $hit,
protected $request,
protected $code,
protected $clickthroughData = [],
protected $unique = false,
) {
$this->entity = $hit;
$this->page = $hit->getPage();
}
/**
* Returns the Page entity.
*
* @return Page
*/
public function getPage()
{
return $this->page;
}
/**
* Get page request.
*
* @return string
*/
public function getRequest()
{
return $this->request;
}
/**
* Get HTML code.
*
* @return mixed
*/
public function getCode()
{
return $this->code;
}
/**
* @return Hit
*/
public function getHit()
{
return $this->entity;
}
/**
* @return mixed
*/
public function getClickthroughData()
{
return $this->clickthroughData;
}
/**
* Returns if this page hit is unique.
*
* @return bool
*/
public function isUnique()
{
return $this->unique;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PageBundle\Entity\Redirect;
class RedirectGenerationEvent extends CommonEvent
{
public function __construct(
private Redirect $redirect,
private array $clickthrough,
) {
}
/**
* Set or overwrite a value in the clickthrough.
*
* @param string $key
* @param mixed $value
*/
public function setInClickthrough($key, $value): void
{
$this->clickthrough[$key] = $value;
}
/**
* Get the redirect from the event.
*
* @return Redirect
*/
public function getRedirect()
{
return $this->redirect;
}
/**
* Get the modified clickthrough from the event.
*
* @return array
*/
public function getClickthrough()
{
return $this->clickthrough;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Event;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\Event;
class TrackingEvent extends Event
{
private ParameterBag $response;
public function __construct(
private Lead $contact,
private Request $request,
array $mtcSessionResponses,
) {
$this->response = new ParameterBag($mtcSessionResponses);
}
public function getContact(): Lead
{
return $this->contact;
}
public function getRequest(): Request
{
return $this->request;
}
public function getResponse(): ParameterBag
{
return $this->response;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\PageBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
class UntrackableUrlsEvent extends Event
{
/**
* @var string[]
*/
private array $doNotTrack = [
'{webview_url}',
'{resubscribe_url}',
'{unsubscribe_url}',
'{dnc_url}',
'{trackable=(.*?)}',
];
/**
* @param mixed $content
*/
public function __construct(
private $content,
) {
}
/**
* set a URL or token to not convert to trackables.
*/
public function addNonTrackable($url): void
{
$this->doNotTrack[] = $url;
}
/**
* Get array of non-trackables.
*
* @return string[]
*/
public function getDoNotTrackList(): array
{
return $this->doNotTrack;
}
/**
* @return string
*/
public function getContent()
{
return $this->content;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\PageBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PageBundle\Entity\VideoHit;
class VideoHitEvent extends CommonEvent
{
public function __construct(
VideoHit $hit,
protected $request,
protected $code,
) {
$this->entity = $hit;
}
/**
* Get page request.
*
* @return string
*/
public function getRequest()
{
return $this->request;
}
/**
* Get HTML code.
*
* @return mixed
*/
public function getCode()
{
return $this->code;
}
/**
* @return VideoHit
*/
public function getHit()
{
return $this->entity;
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\BuildJsEvent;
use Mautic\PageBundle\Helper\TrackingHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
class BuildJsSubscriber implements EventSubscriberInterface
{
public function __construct(
private TrackingHelper $trackingHelper,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::BUILD_MAUTIC_JS => [
// onBuildJs must always needs to be last to ensure setup before delivering the event
['onBuildJs', -255],
['onBuildJsForTrackingEvent', 256],
],
];
}
public function onBuildJs(BuildJsEvent $event): void
{
$pageTrackingUrl = $this->router->generate('mautic_page_tracker', [], UrlGeneratorInterface::ABSOLUTE_URL);
// Determine if this is https
$parts = parse_url($pageTrackingUrl);
$scheme = $parts['scheme'];
$pageTrackingUrl = str_replace(['http://', 'https://'], '', $pageTrackingUrl);
$pageTrackingCORSUrl = str_replace(
['http://', 'https://'],
'',
$this->router->generate('mautic_page_tracker_cors', [], UrlGeneratorInterface::ABSOLUTE_URL)
);
$contactIdUrl = str_replace(
['http://', 'https://'],
'',
$this->router->generate('mautic_page_tracker_getcontact', [], UrlGeneratorInterface::ABSOLUTE_URL)
);
$js = <<<JS
(function(m, l, n, d) {
m.pageTrackingUrl = (l.protocol == 'https:' ? 'https:' : '{$scheme}:') + '//{$pageTrackingUrl}';
m.pageTrackingCORSUrl = (l.protocol == 'https:' ? 'https:' : '{$scheme}:') + '//{$pageTrackingCORSUrl}';
m.contactIdUrl = (l.protocol == 'https:' ? 'https:' : '{$scheme}:') + '//{$contactIdUrl}';
m.getOs = function() {
var OSName="Unknown OS";
if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows";
if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS";
if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX";
if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux";
return OSName;
}
m.deliverPageEvent = function(event, params) {
if (!m.firstDeliveryMade && params['counter'] > 0) {
// Wait for the first delivery to complete so that the tracking information is set
setTimeout(function () {
m.deliverPageEvent(event, params);
}, 5);
return;
}
// Pre delivery events always take all known params and should use them in the request
if (m.preEventDeliveryQueue.length && m.beforeFirstDeliveryMade === false) {
for(var i = 0; i < m.preEventDeliveryQueue.length; i++) {
m.preEventDeliveryQueue[i](params);
}
// In case the first delivery set sid, append it
params = m.appendTrackedContact(params);
m.beforeFirstDeliveryMade = true;
}
MauticJS.makeCORSRequest('POST', m.pageTrackingCORSUrl, params,
function(response) {
MauticJS.dispatchEvent('mauticPageEventDelivered', {'event': event, 'params': params, 'response': response});
},
function() {
// CORS failed so load an image
m.buildTrackingImage(event, params);
m.firstDeliveryMade = true;
});
}
m.buildTrackingImage = function(pageview, params) {
delete m.trackingPixel;
m.trackingPixel = new Image();
if (typeof pageview[3] === 'object') {
var events = ['onabort', 'onerror', 'onload'];
for (var i = 0; i < events.length; i++) {
var e = events[i];
if (typeof pageview[3][e] === 'function') {
m.trackingPixel[e] = pageview[3][e];
}
}
}
m.trackingPixel.onload = function(e) {
MauticJS.dispatchEvent('mauticPageEventDelivered', {'event': pageview, 'params': params, 'image': true});
};
m.trackingPixel.src = m.pageTrackingUrl + '?' + m.serialize(params);
}
m.pageViewCounter = 0;
m.sendPageview = function(pageview) {
var queue = [];
if (!pageview) {
if (typeof m.getInput === 'function') {
queue = m.getInput('send', 'pageview');
} else {
return false;
}
} else {
queue.push(pageview);
}
if (queue) {
for (var i=0; i<queue.length; i++) {
var event = queue[i];
var params = {
page_title: d.title,
page_language: n.language,
preferred_locale: (n.language).replace('-', '_'),
page_referrer: (d.referrer) ? d.referrer.split('/')[2] : '',
page_url: l.href,
counter: m.pageViewCounter,
timezone_offset: new Date().getTimezoneOffset(),
resolution: window.screen.width + 'x' + window.screen.height,
platform: m.getOs(),
do_not_track: navigator.doNotTrack == 1
};
if (window.Intl && window.Intl.DateTimeFormat) {
params.timezone = new window.Intl.DateTimeFormat().resolvedOptions().timeZone;
}
params = MauticJS.appendTrackedContact(params);
// Merge user defined tracking pixel parameters.
if (typeof event[2] === 'object') {
for (var attr in event[2]) {
params[attr] = event[2][attr];
}
}
m.deliverPageEvent(event, params);
m.pageViewCounter++;
}
}
}
// Process pageviews after mtc.js loaded
m.sendPageview();
// Process pageviews after new are added
document.addEventListener('eventAddedToMauticQueue', function(e) {
if (MauticJS.ensureEventContext(e, 'send', 'pageview')) {
m.sendPageview(e.detail);
}
});
})(MauticJS, location, navigator, document);
JS;
$event->appendJs($js, 'Mautic Tracking Pixel');
}
public function onBuildJsForTrackingEvent(BuildJsEvent $event): void
{
$js = '';
$lead = $this->trackingHelper->getLead();
if ($id = $this->trackingHelper->displayInitCode('google_analytics')) {
$gtagSettings = [];
if ($this->trackingHelper->getAnonymizeIp()) {
$gtagSettings['anonymize_ip'] = true;
}
if ($lead && $lead->getId()) {
$gtagSettings['user_id'] = $lead->getId();
}
if (count($gtagSettings) > 0) {
$gtagSettings = ', '.json_encode($gtagSettings);
} else {
$gtagSettings = '';
}
$js .= <<<JS
a = document.createElement('script');
a.async = 1;
a.src = 'https://www.googletagmanager.com/gtag/js?id={$id}';
document.getElementsByTagName('head')[0].appendChild(a);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{$id}'{$gtagSettings});
JS;
}
if ($id = $this->trackingHelper->displayInitCode('facebook_pixel')) {
$customMatch = [];
if ($lead && $lead->getId()) {
$fieldsToMatch = [
'fn' => 'firstname',
'ln' => 'lastname',
'em' => 'email',
'ph' => 'phone',
'ct' => 'city',
'st' => 'state',
'zp' => 'zipcode',
];
foreach ($fieldsToMatch as $key => $fieldToMatch) {
$par = 'get'.ucfirst($fieldToMatch);
if ($value = $lead->{$par}()) {
$customMatch[$key] = $value;
}
}
}
$customMatch = json_encode($customMatch);
$js .= <<<JS
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{$id}'); // Insert your pixel ID here.
fbq('track', 'PageView', {$customMatch});
JS;
}
$js .= <<<'JS_WRAP'
MauticJS.mtcEventSet=false;
document.addEventListener('mauticPageEventDelivered', function(e) {
var detail = e.detail;
if (!MauticJS.mtcEventSet && detail.response && detail.response.events) {
MauticJS.setTrackedEvents(detail.response.events);
}
});
MauticJS.setTrackedEvents = function(events) {
MauticJS.mtcEventSet=true;
if (typeof fbq !== 'undefined' && typeof events.facebook_pixel !== 'undefined') {
var e = events.facebook_pixel;
for(var i = 0; i < e.length; i++) {
if(typeof e[i]['action'] !== 'undefined' && typeof e[i]['label'] !== 'undefined' )
fbq('trackCustom', e[i]['action'], {
eventLabel: e[i]['label']
});
}
}
if (typeof ga !== 'undefined' && typeof events.google_analytics !== 'undefined') {
var e = events.google_analytics;
for(var i = 0; i < e.length; i++) {
if(typeof e[i]['action'] !== 'undefined' && typeof e[i]['label'] !== 'undefined' ) {
ga('send', {
hitType: 'event',
eventCategory: e[i]['category'],
eventAction: e[i]['action'],
eventLabel: e[i]['label'],
});
}
}
}
if (typeof events.focus_item !== 'undefined') {
var e = events.focus_item;
for(var i = 0; i < e.length; i++) {
if(typeof e[i]['id'] !== 'undefined' && typeof e[i]['js'] !== 'undefined' ){
MauticJS.insertScript(e[i]['js']);
}
}
}
};
JS_WRAP;
$event->appendJs($js, 'Mautic 3rd party tracking pixels');
}
}

View File

@@ -0,0 +1,449 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Doctrine\DBAL\Connection;
use Mautic\CoreBundle\Helper\BuilderTokenHelperFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailBuilderEvent;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event as Events;
use Mautic\PageBundle\Helper\TokenHelper;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\PageEvents;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
final class BuilderSubscriber implements EventSubscriberInterface
{
private const pageTokenRegex = '{pagelink=(.*?)}';
private const dwcTokenRegex = '{dwc=(.*?)}';
private const langBarRegex = '{langbar}';
private const shareButtonsRegex = '{sharebuttons}';
private const titleRegex = '{pagetitle}';
private const descriptionRegex = '{pagemetadescription}';
public const brandName = '{brand=name}';
public const segmentListRegex = '{segmentlist}';
public const categoryListRegex = '{categorylist}';
public const channelfrequency = '{channelfrequency}';
public const preferredchannel = '{preferredchannel}';
public const saveprefsRegex = '{saveprefsbutton}';
public const successmessage = '{successmessage}';
public const identifierToken = '{leadidentifier}';
public const saveButtonContainerClass = 'prefs-saveprefs';
public const firstSlotAttribute = ' data-prefs-center-first="1"';
/**
* @var array<string,string>
*/
private array $renderedContentCache = [];
public function __construct(private TokenHelper $tokenHelper, private IntegrationHelper $integrationHelper, private PageModel $pageModel, private BuilderTokenHelperFactory $builderTokenHelperFactory, private TranslatorInterface $translator, private Connection $connection, private Environment $twig, private CoreParametersHelper $coreParametersHelper)
{
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_DISPLAY => ['onPageDisplay', 0],
PageEvents::PAGE_ON_BUILD => ['onPageBuild', 0],
EmailEvents::EMAIL_ON_BUILD => ['onEmailBuild', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailGenerate', 0],
EmailEvents::EMAIL_ON_DISPLAY => ['onEmailGenerate', 0],
];
}
public function onEmailBuild(EmailBuilderEvent $event): void
{
if ($event->tokensRequested([static::pageTokenRegex])) {
$tokenHelper = $this->builderTokenHelperFactory->getBuilderTokenHelper('page');
$event->addTokensFromHelper($tokenHelper, static::pageTokenRegex, 'title', 'id', true);
}
}
public function onEmailGenerate(EmailSendEvent $event): void
{
$content = $event->getContent();
$plainText = $event->getPlainText();
$clickthrough = $event->shouldAppendClickthrough() ? $event->generateClickthrough() : [];
$tokens = $this->tokenHelper->findPageTokens($content.$plainText, $clickthrough);
$event->addTokens($tokens);
}
/**
* Add forms to available page tokens.
*/
public function onPageBuild(Events\PageBuilderEvent $event): void
{
$tokenHelper = $this->builderTokenHelperFactory->getBuilderTokenHelper('page');
if ($event->abTestWinnerCriteriaRequested()) {
// add AB Test Winner Criteria
$bounceRate = [
'group' => 'mautic.page.abtest.criteria',
'label' => 'mautic.page.abtest.criteria.bounce',
'event' => PageEvents::ON_DETERMINE_BOUNCE_RATE_WINNER,
];
$event->addAbTestWinnerCriteria('page.bouncerate', $bounceRate);
$dwellTime = [
'group' => 'mautic.page.abtest.criteria',
'label' => 'mautic.page.abtest.criteria.dwelltime',
'event' => PageEvents::ON_DETERMINE_DWELL_TIME_WINNER,
];
$event->addAbTestWinnerCriteria('page.dwelltime', $dwellTime);
}
if ($event->tokensRequested([static::pageTokenRegex, static::dwcTokenRegex])) {
$event->addTokensFromHelper($tokenHelper, static::pageTokenRegex, 'title', 'id', true);
// add only filter based dwc tokens
$dwcTokenHelper = $this->builderTokenHelperFactory->getBuilderTokenHelper('dynamicContent', 'dynamiccontent:dynamiccontents');
$expr = $this->connection->createExpressionBuilder()->and('e.is_campaign_based <> 1 and e.slot_name is not null');
$tokens = $dwcTokenHelper->getTokens(
static::dwcTokenRegex,
'',
'name',
'slot_name',
$expr
);
$event->addTokens(is_array($tokens) ? $tokens : []);
$event->addTokens(
$event->filterTokens(
[
static::langBarRegex => $this->translator->trans('mautic.page.token.lang'),
static::shareButtonsRegex => $this->translator->trans('mautic.page.token.share'),
static::titleRegex => $this->translator->trans('mautic.core.title'),
static::brandName => $this->translator->trans('mautic.core.token.brand_name'),
static::descriptionRegex => $this->translator->trans('mautic.page.form.metadescription'),
static::segmentListRegex => $this->translator->trans('mautic.page.form.segmentlist'),
static::categoryListRegex => $this->translator->trans('mautic.page.form.categorylist'),
static::preferredchannel => $this->translator->trans('mautic.page.form.preferredchannel'),
static::channelfrequency => $this->translator->trans('mautic.page.form.channelfrequency'),
static::saveprefsRegex => $this->translator->trans('mautic.page.form.saveprefs'),
static::successmessage => $this->translator->trans('mautic.page.form.successmessage'),
static::identifierToken => $this->translator->trans('mautic.page.form.leadidentifier'),
]
)
);
}
}
public function onPageDisplay(Events\PageDisplayEvent $event): void
{
if (empty($content = $event->getContent())) {
return;
}
$page = $event->getPage();
$params = $event->getParams();
$content = $this->replaceCommonTokens($content, $page);
if ($page->getIsPreferenceCenter()) {
$content = $this->handlePreferenceCenterReplacements($content, $params);
}
if ($tokens = $this->tokenHelper->findPageTokens($content, ['source' => ['page', $page->getId()]])) {
$content = str_ireplace(array_keys($tokens), $tokens, $content);
}
$headCloseScripts = $page->getHeadScript();
if ($headCloseScripts) {
$content = str_ireplace('</head>', $headCloseScripts."\n</head>", $content);
}
$bodyCloseScripts = $page->getFooterScript();
if ($bodyCloseScripts) {
$content = str_ireplace('</body>', $bodyCloseScripts."\n</body>", $content);
}
$event->setContent($content);
}
private function replaceCommonTokens(string $content, Page $page): string
{
return str_ireplace([
static::langBarRegex,
static::shareButtonsRegex,
static::titleRegex,
static::brandName,
static::descriptionRegex,
static::successmessage,
], [
str_contains($content, static::langBarRegex) ? $this->renderLanguageBar($page) : '',
str_contains($content, static::shareButtonsRegex) ? $this->renderSocialShareButtons() : '',
str_contains($content, static::titleRegex) ? $page->getTitle() : '',
str_contains($content, static::brandName) ? $this->coreParametersHelper->get('brand_name') : '',
str_contains($content, static::descriptionRegex) ? $page->getMetaDescription() : '',
str_contains($content, static::successmessage) ? $this->renderSuccessMessage() : '',
], $content);
}
/**
* @param array<string,mixed> $params
*/
private function handlePreferenceCenterReplacements(string $content, array $params): string
{
$xpath = $this->createDOMXPathForContent($content);
$content = $this->replacePreferenceCenterTokens($xpath->document->saveHTML(), $params);
return $this->wrapPreferenceCenterInFormTag($content, $params);
}
/**
* @param array<string,mixed> $params
*/
private function replacePreferenceCenterTokens(string $content, array $params): string
{
return str_ireplace([
static::segmentListRegex,
static::categoryListRegex,
static::preferredchannel,
static::channelfrequency,
static::saveprefsRegex,
], [
str_contains($content, static::segmentListRegex) ? $this->renderSegmentList($params) : '',
str_contains($content, static::categoryListRegex) ? $this->renderCategoryList($params) : '',
str_contains($content, static::preferredchannel) ? $this->renderPreferredChannel($params) : '',
str_contains($content, static::channelfrequency) ? $this->renderChannelFrequency($params) : '',
str_contains($content, static::saveprefsRegex) ? $this->renderSavePrefs($params) : '',
], $content);
}
/**
* @param mixed[] $templateParams
*/
private function renderTemplate(string $templateName, array $templateParams, string $wrapperTemplate = '', string ...$wrapperTemplateValues): string
{
if (!empty($this->renderedContentCache[$templateName])) {
return $this->renderedContentCache[$templateName];
}
$content = trim($this->twig->render($templateName, $templateParams));
if ($wrapperTemplate) {
// If the content is not empty, ensure that the $wrapperTemplate contains a place to put it.
if (!empty($content) && !str_contains($wrapperTemplate, '{templateContent}')) {
throw new \InvalidArgumentException('Your $wrapperTemplate must contain the string {templateContent} where you want to insert the rendered template content.');
}
$content = str_replace('{templateContent}', $content, sprintf($wrapperTemplate, ...$wrapperTemplateValues));
}
return $this->renderedContentCache[$templateName] = $content;
}
private function renderSocialShareButtons(): string
{
return $this->renderTemplate(
'@MauticPage/SubscribedEvents/PageToken/sharebtn_css.html.twig',
[],
'<div class="share-buttons">%s</div>',
implode('', $this->integrationHelper->getShareButtons())
);
}
private function renderSegmentList(array $params): string
{
return $this->renderTemplate(
'@MauticCore/Slots/segmentlist.html.twig',
$params,
'<div class="pref-segmentlist"%s>{templateContent}</div>',
static::firstSlotAttribute
);
}
private function renderCategoryList(array $params): string
{
return $this->renderTemplate(
'@MauticCore/Slots/categorylist.html.twig',
$params,
'<div class="pref-categorylist"%s>{templateContent}</div>',
static::firstSlotAttribute
);
}
private function renderPreferredChannel(array $params): string
{
return $this->renderTemplate(
'@MauticCore/Slots/preferredchannel.html.twig',
$params,
'<div class="pref-preferredchannel">{templateContent}</div>'
);
}
private function renderChannelFrequency(array $params): string
{
return $this->renderTemplate(
'@MauticCore/Slots/channelfrequency.html.twig',
$params,
'<div class="pref-channelfrequency">{templateContent}</div>'
);
}
private function renderSavePrefs(array $params): string
{
return $this->renderTemplate(
'@MauticCore/Slots/saveprefsbutton.html.twig',
$params,
'<div class="%s"%s>{templateContent}</div>',
static::saveButtonContainerClass,
static::firstSlotAttribute
);
}
private function renderSuccessMessage(): string
{
return $this->renderTemplate(
'@MauticCore/Slots/successmessage.html.twig',
[],
'<div class="pref-successmessage">{templateContent}</div>'
);
}
private function renderLanguageBar(Page $page): string
{
return $this->renderTemplate(
'@MauticPage/SubscribedEvents/PageToken/langbar.html.twig',
['pages' => $this->getRelatedPagesForLanguageBar($page)]
);
}
/**
* @return array<int,mixed[]>
*/
private function getRelatedPagesForLanguageBar(Page $page): array
{
$related = [];
$parent = $page->getTranslationParent();
$children = $page->getTranslationChildren();
if (empty($parent) && empty($children)) {
return $related;
}
// If this page has a parent, then fetch the children from the parent
if (!empty($parent)) {
$children = $parent->getTranslationChildren();
} else {
// Otherwise this is the parent page.
$parent = $page;
}
if (empty($children)) {
return $related;
}
if ($parent instanceof Page) {
$related[$parent->getId()] = $this->buildRelatedArrayForPage($parent);
}
foreach ($children as $child) {
$related[$child->getId()] = $this->buildRelatedArrayForPage($child);
}
uasort($related, fn ($a, $b): int => strnatcasecmp($a['lang'], $b['lang']));
return $related;
}
/**
* @return array<string,string>
*/
private function buildRelatedArrayForPage(Page $page): array
{
$language = $page->getLanguage();
$translated = $this->translator->trans('mautic.page.lang.'.$language);
if ($translated == 'mautic.page.lang.'.$language) {
$translated = $language;
}
return [
'lang' => $translated,
// Add ntrd to not auto redirect to another language
'url' => $this->pageModel->generateUrl($page, false).'?ntrd=1',
];
}
private function createDOMXPathForContent(string $content): \DOMXPath
{
$domDocument = new \DOMDocument('1.0', 'utf-8');
$domDocument->loadHTML(mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, 0xFFFFF], 'UTF-8'), LIBXML_NOERROR);
return new \DOMXPath($domDocument);
}
/**
* @param mixed[] $params
*/
private function wrapPreferenceCenterInFormTag(string $content, array $params): string
{
if (!isset($params['startform']) || !str_contains($content, 'data-prefs-center')) {
return $content;
}
$xpath = $this->createDOMXPathForContent($content);
$node = $this->getFirstNodeThatContainsAPreferenceCenterToken($xpath);
if (null === $node) {
return $content;
}
$parentNode = $this->getFirstParentNodeThatContainsAllFormInputs($node);
$parentNode->insertBefore(new \DOMElement('startform'), $parentNode->firstChild);
$parentNode->appendChild(new \DOMElement('endform'));
return str_replace(['<startform></startform>', '<endform></endform>'], [$params['startform'], '</form>'], $xpath->document->saveHTML());
}
private function getFirstNodeThatContainsAPreferenceCenterToken(\DOMXPath $xpath): ?\DOMNode
{
$nodeList = $xpath->query('//*[@data-prefs-center-first="1"]');
if (false !== $nodeList) {
return $nodeList->item(0);
}
return null;
}
private function getFirstParentNodeThatContainsAllFormInputs(\DOMNode $node): \DOMNode
{
$content = implode('', array_map([$node->ownerDocument, 'saveHTML'], iterator_to_array($node->childNodes)));
// Check if the save button exists in the content. If not, try again with the parentNode.
if (!str_contains($content, static::saveButtonContainerClass)) {
if (null === $node->parentNode) {
throw new \RuntimeException("Can't get parent node of #document. Did you forget to insert a save button in your preference center form?");
}
return $this->getFirstParentNodeThatContainsAllFormInputs($node->parentNode);
}
return $node;
}
}

View File

@@ -0,0 +1,236 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\LeadBundle\Form\Type\CampaignEventLeadDeviceType;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageHitEvent;
use Mautic\PageBundle\Form\Type\CampaignEventPageHitType;
use Mautic\PageBundle\Form\Type\TrackingPixelSendType;
use Mautic\PageBundle\Helper\TrackingHelper;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
public function __construct(
private LeadModel $leadModel,
private TrackingHelper $trackingHelper,
private RealTimeExecutioner $realTimeExecutioner,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
PageEvents::PAGE_ON_HIT => ['onPageHit', 0],
PageEvents::ON_CAMPAIGN_TRIGGER_DECISION => [
['onCampaignTriggerDecision', 0],
['onCampaignTriggerDecisionDeviceHit', 1],
],
PageEvents::ON_CAMPAIGN_TRIGGER_ACTION => ['onCampaignTriggerAction', 0],
];
}
/**
* Add event triggers and actions.
*/
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
// Add trigger
$pageHitTrigger = [
'label' => 'mautic.page.campaign.event.pagehit',
'description' => 'mautic.page.campaign.event.pagehit_descr',
'formType' => CampaignEventPageHitType::class,
'eventName' => PageEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'channel' => 'page',
'channelIdField' => 'pages',
];
$event->addDecision('page.pagehit', $pageHitTrigger);
// Add trigger
$deviceHitTrigger = [
'label' => 'mautic.page.campaign.event.devicehit',
'description' => 'mautic.page.campaign.event.devicehit_descr',
'formType' => CampaignEventLeadDeviceType::class,
'eventName' => PageEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'channel' => 'page',
'channelIdField' => 'pages',
];
$event->addDecision('page.devicehit', $deviceHitTrigger);
$trackingServices = $this->trackingHelper->getEnabledServices();
if (!empty($trackingServices)) {
$action = [
'label' => 'mautic.page.tracking.pixel.event.send',
'description' => 'mautic.page.tracking.pixel.event.send_desc',
'eventName' => PageEvents::ON_CAMPAIGN_TRIGGER_ACTION,
'formType' => TrackingPixelSendType::class,
'connectionRestrictions' => [
'anchor' => [
'decision.inaction',
],
'source' => [
'decision' => [
'page.pagehit',
],
],
],
];
$event->addAction('tracking.pixel.send', $action);
}
}
/**
* Trigger actions for page hits.
*/
public function onPageHit(PageHitEvent $event): void
{
$hit = $event->getHit();
$channel = 'page';
$channelId = null;
if ($redirect = $hit->getRedirect()) {
$channel = 'page.redirect';
$channelId = $redirect->getId();
} elseif ($page = $hit->getPage()) {
$channelId = $page->getId();
}
$this->realTimeExecutioner->execute('page.pagehit', $hit, $channel, $channelId);
$this->realTimeExecutioner->execute('page.devicehit', $hit, $channel, $channelId);
}
public function onCampaignTriggerDecisionDeviceHit(CampaignExecutionEvent $event)
{
$eventDetails = $event->getEventDetails();
$config = $event->getConfig();
$lead = $event->getLead();
if (!$event->checkContext('page.devicehit')) {
return false;
}
$deviceRepo = $this->leadModel->getDeviceRepository();
$result = false;
$deviceId = $eventDetails->getDeviceStat() ? $eventDetails->getDeviceStat()->getId() : null;
$deviceType = $config['device_type'];
$deviceBrands = $config['device_brand'];
$deviceOs = $config['device_os'];
if (!empty($deviceType)) {
$result = false;
if (!empty($deviceRepo->getDevice($lead, $deviceType, null, null, null, $deviceId))) {
$result = true;
}
}
if (!empty($deviceBrands)) {
$result = false;
if (!empty($deviceRepo->getDevice($lead, null, $deviceBrands, null, null, $deviceId))) {
$result = true;
}
}
if (!empty($deviceOs)) {
$result = false;
if (!empty($deviceRepo->getDevice($lead, null, null, null, $deviceOs, $deviceId))) {
$result = true;
}
}
return $event->setResult($result);
}
public function onCampaignTriggerDecision(CampaignExecutionEvent $event)
{
$eventDetails = $event->getEventDetails();
$config = $event->getConfig();
if (!$event->checkContext('page.pagehit')) {
return false;
}
if (null == $eventDetails) {
return true;
}
$pageHit = $eventDetails->getPage();
// Check Landing Pages
if ($pageHit instanceof Page) {
[$parent, $children] = $pageHit->getVariants();
// use the parent (self or configured parent)
$pageHitId = $parent->getId();
} else {
$pageHitId = 0;
}
$limitToPages = $config['pages'] ?? [];
$urlMatches = [];
// Check Landing Pages URL or Tracing Pixel URL
if (isset($config['url']) && $config['url']) {
$pageUrl = html_entity_decode($eventDetails->getUrl());
$limitToUrls = explode(',', $config['url']);
foreach ($limitToUrls as $url) {
$url = html_entity_decode(trim($url));
$urlMatches[$url] = fnmatch($url, $pageUrl);
}
}
$refererMatches = [];
// Check Landing Pages URL or Tracing Pixel URL
if (isset($config['referer']) && $config['referer']) {
$refererUrl = html_entity_decode($eventDetails->getReferer());
$limitToReferers = explode(',', $config['referer']);
foreach ($limitToReferers as $referer) {
$referer = html_entity_decode(trim($referer));
$refererMatches[$referer] = fnmatch($referer, $refererUrl);
}
}
// **Page hit is true if:**
// 1. no landing page is set and no URL rule is set
$applyToAny = (empty($config['url']) && empty($config['referer']) && empty($limitToPages));
// 2. some landing pages are set and page ID match
$langingPageIsHit = (!empty($limitToPages) && in_array($pageHitId, $limitToPages));
// 3. URL rule is set and match with URL hit
$urlIsHit = (!empty($config['url']) && in_array(true, $urlMatches));
// 3. URL rule is set and match with URL hit
$refererIsHit = (!empty($config['referer']) && in_array(true, $refererMatches));
if ($applyToAny || $langingPageIsHit || $urlIsHit || $refererIsHit) {
return $event->setResult(true);
}
return $event->setResult(false);
}
public function onCampaignTriggerAction(CampaignExecutionEvent $event)
{
$config = $event->getConfig();
if (empty($config['services'])) {
return $event->setResult(false);
}
$values = [];
foreach ($config['services'] as $service) {
$values[$service][] = ['category' => $config['category'], 'action' => $config['action'], 'label' => $config['label']];
}
$this->trackingHelper->updateCacheItem($values);
return $event->setResult(true);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\PageBundle\Form\Type\ConfigTrackingPageType;
use Mautic\PageBundle\Form\Type\ConfigType;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => [
['onConfigGenerate', 0],
['onConfigGenerateTracking', 0],
],
ConfigEvents::CONFIG_PRE_SAVE => ['onConfigSave', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'PageBundle',
'formAlias' => 'pageconfig',
'formType' => ConfigType::class,
'formTheme' => '@MauticPage/FormTheme/Config/_config_pageconfig_widget.html.twig',
// parameters must be defined directly in case there are 2 config forms per bundle.
// $event->getParametersFromConfig('MauticPageBundle') would return all params for PageBundle
// and trackingconfig form would overwrote values in the pageconfig form. See #5559.
'parameters' => [
'cat_in_page_url' => false,
'google_analytics' => false,
],
]);
}
public function onConfigGenerateTracking(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'PageBundle',
'formAlias' => 'trackingconfig',
'formType' => ConfigTrackingPageType::class,
'formTheme' => '@MauticPage/FormTheme/Config/_config_trackingconfig_widget.html.twig',
// parameters defined this way because of the reason as above.
'parameters' => [
'anonymize_ip' => false,
'track_contact_by_ip' => false,
'facebook_pixel_id' => null,
'facebook_pixel_trackingpage_enabled' => false,
'facebook_pixel_landingpage_enabled' => false,
'google_analytics_id' => null,
'google_analytics_trackingpage_enabled' => false,
'google_analytics_landingpage_enabled' => false,
'google_analytics_anonymize_ip' => false,
'do_not_track_404_anonymous' => false,
],
]);
}
public function onConfigSave(ConfigEvent $event): void
{
$values = $event->getConfig();
if (!empty($values['pageconfig']['google_analytics'])) {
$values['pageconfig']['google_analytics'] = htmlspecialchars($values['pageconfig']['google_analytics']);
$event->setConfig($values);
}
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\EventListener\DashboardSubscriber as MainDashboardSubscriber;
use Mautic\PageBundle\Form\Type\DashboardHitsInTimeWidgetType;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\Routing\RouterInterface;
class DashboardSubscriber extends MainDashboardSubscriber
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'page';
/**
* Define the widget(s).
*
* @var string
*/
protected $types = [
'page.hits.in.time' => [
'formAlias' => DashboardHitsInTimeWidgetType::class,
],
'unique.vs.returning.leads' => [],
'dwell.times' => [],
'popular.pages' => [],
'created.pages' => [],
'device.granularity' => [],
];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [
'page:pages:viewown',
'page:pages:viewother',
];
public function __construct(
protected PageModel $pageModel,
protected RouterInterface $router,
) {
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
$this->checkPermissions($event);
$canViewOthers = $event->hasPermission('page:pages:viewother');
if ('page.hits.in.time' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (isset($params['flag'])) {
$params['filter']['flag'] = $params['flag'];
}
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'line',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->pageModel->getHitsLineChartData(
$params['timeUnit'],
$params['dateFrom'],
$params['dateTo'],
$params['dateFormat'],
$params['filter'],
$canViewOthers
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('unique.vs.returning.leads' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $event->getWidget()->getHeight() - 80,
'chartData' => $this->pageModel->getUniqueVsReturningPieChartData($params['dateFrom'], $params['dateTo'], $canViewOthers),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('dwell.times' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $event->getWidget()->getHeight() - 80,
'chartData' => $this->pageModel->getDwellTimesPieChartData($params['dateFrom'], $params['dateTo'], [], $canViewOthers),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('popular.pages' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
if (empty($params['limit'])) {
// Count the pages limit from the widget height
$limit = round((($event->getWidget()->getHeight() - 80) / 35) - 1);
} else {
$limit = $params['limit'];
}
$pages = $this->pageModel->getPopularPages($limit, $params['dateFrom'], $params['dateTo'], [], $canViewOthers);
$items = [];
// Build table rows with links
foreach ($pages as &$page) {
$pageUrl = $this->router->generate('mautic_page_action', ['objectAction' => 'view', 'objectId' => $page['id']]);
$row = [
[
'value' => $page['title'],
'type' => 'link',
'link' => $pageUrl,
],
[
'value' => $page['hits'],
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
'mautic.dashboard.label.hits',
],
'bodyItems' => $items,
'raw' => $pages,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('created.pages' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
if (empty($params['limit'])) {
// Count the pages limit from the widget height
$limit = round((($event->getWidget()->getHeight() - 80) / 35) - 1);
} else {
$limit = $params['limit'];
}
$pages = $this->pageModel->getPageList($limit, $params['dateFrom'], $params['dateTo'], [], $canViewOthers);
$items = [];
// Build table rows with links
foreach ($pages as &$page) {
$pageUrl = $this->router->generate('mautic_page_action', ['objectAction' => 'view', 'objectId' => $page['id']]);
$row = [
[
'value' => $page['name'],
'type' => 'link',
'link' => $pageUrl,
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
],
'bodyItems' => $items,
'raw' => $pages,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('device.granularity' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->pageModel->getDeviceGranularityData(
$params['dateFrom'],
$params['dateTo'],
[],
$canViewOthers
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\Helper\DateTime\DateTimeToken;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Event\PageBuilderEvent;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class DateTimeTokenSubscriber implements EventSubscriberInterface
{
public function __construct(
private TranslatorInterface $translator,
private DateTimeToken $dateTokenHelper,
private CorePermissions $security,
private ContactTracker $contactTracker,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_BUILD => ['onPageBuild', 0],
PageEvents::PAGE_ON_DISPLAY => ['onPageDisplay', 0],
];
}
public function onPageBuild(PageBuilderEvent $event): void
{
$event->addToken('{today}', $this->translator->trans('mautic.email.token.today'));
}
public function onPageDisplay(PageDisplayEvent $event): void
{
$content = $event->getContent();
$contact = $this->security->isAnonymous() ? $this->contactTracker->getContact() : null;
$tokenList = $this->dateTokenHelper->getTokens($content, $contact ? $contact->getTimezone() : null);
$event->setContent(str_replace(array_keys($tokenList), $tokenList, $content));
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\Event\DetermineWinnerEvent;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class DetermineWinnerSubscriber implements EventSubscriberInterface
{
public function __construct(
private HitRepository $hitRepository,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::ON_DETERMINE_BOUNCE_RATE_WINNER => ['onDetermineBounceRateWinner', 0],
PageEvents::ON_DETERMINE_DWELL_TIME_WINNER => ['onDetermineDwellTimeWinner', 0],
];
}
/**
* Determines the winner of A/B test based on bounce rates.
*/
public function onDetermineBounceRateWinner(DetermineWinnerEvent $event): void
{
// find the hits that did not go any further
$parent = $event->getParameters()['parent'];
$children = $event->getParameters()['children'];
$pageIds = $parent->getRelatedEntityIds();
$startDate = $parent->getVariantStartDate();
if (null != $startDate && !empty($pageIds)) {
// get their bounce rates
$counts = $this->hitRepository->getBounces($pageIds, $startDate, true);
if ($counts) {
// Group by translation
$combined = [
$parent->getId() => $counts[$parent->getId()],
];
if ($parent->hasTranslations()) {
$translations = $parent->getTranslationChildren()->getKeys();
foreach ($translations as $translation) {
$combined[$parent->getId()]['bounces'] += $counts[$translation]['bounces'];
$combined[$parent->getId()]['totalHits'] += $counts[$translation]['totalHits'];
$combined[$parent->getId()]['rate'] = ($combined[$parent->getId()]['totalHits']) ? round(
($combined[$parent->getId()]['bounces'] / $combined[$parent->getId()]['totalHits']) * 100,
2
) : 0;
}
}
foreach ($children as $child) {
$combined[$child->getId()] = $counts[$child->getId()];
if ($child->hasTranslations()) {
$translations = $child->getTranslationChildren()->getKeys();
foreach ($translations as $translation) {
$combined[$child->getId()]['bounces'] += $counts[$translation]['bounces'];
$combined[$child->getId()]['totalHits'] += $counts[$translation]['totalHits'];
$combined[$child->getId()]['rate'] = ($combined[$child->getId()]['totalHits']) ? round(
($combined[$child->getId()]['bounces'] / $combined[$child->getId()]['totalHits']) * 100,
2
) : 0;
}
}
}
unset($counts);
// let's arrange by rate
$rates = [];
$support['data'] = [];
$support['labels'] = [];
$bounceLabel = $this->translator->trans('mautic.page.abtest.label.bounces');
foreach ($combined as $pid => $stats) {
$rates[$pid] = $stats['rate'];
$support['data'][$bounceLabel][] = $rates[$pid];
$support['labels'][] = $pid.':'.$stats['title'];
}
$min = min($rates);
$support['step_width'] = (ceil($min / 10) * 10);
$winners = ($min >= 0) ? array_keys($rates, $min) : [];
$event->setAbTestResults([
'winners' => $winners,
'support' => $support,
'basedOn' => 'page.bouncerate',
'supportTemplate' => '@MauticPage/SubscribedEvents/AbTest/bargraph.html.twig',
]);
return;
}
}
$event->setAbTestResults([
'winners' => [],
'support' => [],
'basedOn' => 'page.bouncerate',
]);
}
/**
* Determines the winner of A/B test based on dwell time rates.
*/
public function onDetermineDwellTimeWinner(DetermineWinnerEvent $event): void
{
// find the hits that did not go any further
$parent = $event->getParameters()['parent'];
$pageIds = $parent->getRelatedEntityIds();
$startDate = $parent->getVariantStartDate();
if (null != $startDate && !empty($pageIds)) {
// get their bounce rates
$counts = $this->hitRepository->getDwellTimesForPages($pageIds, ['fromDate' => $startDate]);
$support = [];
if ($counts) {
// in order to get a fair grade, we have to compare the averages here since a page that is only shown
// 25% of the time will have a significantly lower sum than a page shown 75% of the time
$avgs = [];
$support['data'] = [];
$support['labels'] = [];
foreach ($counts as $pid => $stats) {
$avgs[$pid] = $stats['average'];
$support['data'][$this->translator->trans('mautic.page.abtest.label.dewlltime.average')][] = $stats['average'];
$support['labels'][] = $pid.':'.$stats['title'];
}
// set max for scales
$max = max($avgs);
$support['step_width'] = (ceil($max / 10) * 10);
// get the page ids with the greatest average dwell time
$winners = ($max > 0) ? array_keys($avgs, $max) : [];
$event->setAbTestResults([
'winners' => $winners,
'support' => $support,
'basedOn' => 'page.dwelltime',
'supportTemplate' => '@MauticPage/SubscribedEvents/AbTest/bargraph.html.twig',
]);
return;
}
}
$event->setAbTestResults([
'winners' => [],
'support' => [],
'basedOn' => 'page.dwelltime',
]);
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\EventListener\ChannelTrait;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\LeadBundle\Event\LeadChangeEvent;
use Mautic\LeadBundle\Event\LeadMergeEvent;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Model\ChannelTimelineInterface;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\VideoModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
use ChannelTrait;
/**
* @var RouterInterface
*/
private $router;
/**
* @param ModelFactory<object> $modelFactory
*/
public function __construct(
private PageModel $pageModel,
private VideoModel $pageVideoModel,
private TranslatorInterface $translator,
RouterInterface $router,
ModelFactory $modelFactory,
) {
$this->router = $router;
$this->setModelFactory($modelFactory);
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => [
['onTimelineGenerate', 0],
['onTimelineGenerateVideo', 0],
],
LeadEvents::CURRENT_LEAD_CHANGED => ['onLeadChange', 0],
LeadEvents::LEAD_POST_MERGE => ['onLeadMerge', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
// Set available event types
$eventTypeKey = 'page.hit';
$eventTypeName = $this->translator->trans('mautic.page.event.hit');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup(['pageList', 'hitDetails']);
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$hits = $this->pageModel->getHitRepository()->getLeadHits(
$event->getLeadId(),
$event->getQueryOptions()
);
// Add to counter
$event->addToCounter($eventTypeKey, $hits);
if (!$event->isEngagementCount()) {
// Add the hits to the event array
foreach ($hits['results'] as $hit) {
$template = '@MauticPage/SubscribedEvents/Timeline/index.html.twig';
$icon = 'ri-link';
if (!empty($hit['source'])) {
if ($channelModel = $this->getChannelModel($hit['source'])) {
if ($channelModel instanceof ChannelTimelineInterface) {
if ($overrideTemplate = $channelModel->getChannelTimelineTemplate($eventTypeKey, $hit)) {
$template = $overrideTemplate;
}
if ($overrideEventTypeName = $channelModel->getChannelTimelineLabel($eventTypeKey, $hit)) {
$eventTypeName = $overrideEventTypeName;
}
if ($overrideIcon = $channelModel->getChannelTimelineIcon($eventTypeKey, $hit)) {
$icon = $overrideIcon;
}
}
if (!empty($hit['sourceId'])) {
if ($source = $this->getChannelEntityName($hit['source'], $hit['sourceId'], true)) {
$hit['sourceName'] = $source['name'];
$hit['sourceRoute'] = $source['url'];
}
}
}
}
if (!empty($hit['page_id'])) {
$page = $this->pageModel->getEntity($hit['page_id']);
$eventLabel = [
'label' => $page->getTitle(),
'href' => $this->router->generate('mautic_page_action', ['objectAction' => 'view', 'objectId' => $hit['page_id']]),
];
} else {
$eventLabel = [
'label' => $hit['urlTitle'] ?? $hit['url'],
'href' => $hit['url'],
'isExternal' => true,
];
}
$contactId = $hit['lead_id'];
unset($hit['lead_id']);
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $hit['hitId'],
'eventLabel' => $eventLabel,
'eventType' => $eventTypeName,
'timestamp' => $hit['dateHit'],
'extra' => [
'hit' => $hit,
],
'contentTemplate' => $template,
'icon' => $icon,
'contactId' => $contactId,
]
);
}
}
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerateVideo(LeadTimelineEvent $event): void
{
// Set available event types
$eventTypeKey = 'page.videohit';
$eventTypeName = $this->translator->trans('mautic.page.event.videohit');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup(['pageList', 'hitDetails']);
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$hits = $this->pageVideoModel->getHitRepository()->getTimelineStats(
$event->getLeadId(),
$event->getQueryOptions()
);
$event->addToCounter($eventTypeKey, $hits);
if (!$event->isEngagementCount()) {
// Add the hits to the event array
foreach ($hits['results'] as $hit) {
$template = '@MauticPage/SubscribedEvents/Timeline/videohit.html.twig';
$eventLabel = $eventTypeName;
$event->addEvent(
[
'event' => $eventTypeKey,
'eventLabel' => $eventLabel,
'eventType' => $eventTypeName,
'timestamp' => $hit['date_hit'],
'extra' => [
'hit' => $hit,
],
'contentTemplate' => $template,
'icon' => 'ri-vidicon-2-line',
]
);
}
}
}
public function onLeadChange(LeadChangeEvent $event): void
{
$this->pageModel->getHitRepository()->updateLeadByTrackingId(
$event->getNewLead()->getId(),
$event->getNewTrackingId(),
$event->getOldTrackingId()
);
}
public function onLeadMerge(LeadMergeEvent $event): void
{
$this->pageModel->getHitRepository()->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
$this->pageVideoModel->getHitRepository()->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Doctrine\DBAL\Connection;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\MaintenanceEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class MaintenanceSubscriber implements EventSubscriberInterface
{
public function __construct(
private Connection $db,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::MAINTENANCE_CLEANUP_DATA => ['onDataCleanup', 10], // Cleanup before visitors are processed
];
}
public function onDataCleanup(MaintenanceEvent $event): void
{
$this->cleanData($event, 'page_hits');
$this->cleanData($event, 'lead_utmtags');
}
private function cleanData(MaintenanceEvent $event, $table): void
{
$qb = $this->db->createQueryBuilder()
->setParameter('date', $event->getDate()->format('Y-m-d H:i:s'));
if ($event->isDryRun()) {
$qb->select('count(*) as records')
->from(MAUTIC_TABLE_PREFIX.$table, 'h')
->join('h', MAUTIC_TABLE_PREFIX.'leads', 'l', 'h.lead_id = l.id')
->where($qb->expr()->lte('l.last_active', ':date'));
if (false === $event->isGdpr()) {
$qb->andWhere($qb->expr()->isNull('l.date_identified'));
} else {
$qb->orWhere(
$qb->expr()->and(
$qb->expr()->lte('l.date_added', ':date2'),
$qb->expr()->isNull('l.last_active')
));
$qb->setParameter('date2', $event->getDate()->format('Y-m-d H:i:s'));
}
$rows = $qb->executeQuery()->fetchOne();
} else {
$subQb = $this->db->createQueryBuilder();
$subQb->select('id')->from(MAUTIC_TABLE_PREFIX.'leads', 'l')
->where($qb->expr()->lte('l.last_active', ':date'));
if (false === $event->isGdpr()) {
$subQb->andWhere($qb->expr()->isNull('l.date_identified'));
} else {
$subQb->orWhere(
$subQb->expr()->and(
$subQb->expr()->lte('l.date_added', ':date2'),
$subQb->expr()->isNull('l.last_active')
));
$subQb->setParameter('date2', $event->getDate()->format('Y-m-d H:i:s'));
}
$rows = 0;
$loop = 0;
$subQb->setParameter('date', $event->getDate()->format('Y-m-d H:i:s'));
while (true) {
$subQb->setMaxResults(10000)->setFirstResult($loop * 10000);
$leadsIds = array_column($subQb->executeQuery()->fetchAllAssociative(), 'id');
if (0 === sizeof($leadsIds)) {
break;
}
$rows += $qb->delete(MAUTIC_TABLE_PREFIX.$table)
->where(
$qb->expr()->in(
'lead_id', $leadsIds
)
)
->executeStatement();
++$loop;
}
}
$event->setStat($this->translator->trans('mautic.maintenance.'.$table), $rows, $qb->getSQL(), $qb->getParameters());
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Event\EntityExportEvent;
use Mautic\CoreBundle\Event\EntityImportAnalyzeEvent;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Event\EntityImportUndoEvent;
use Mautic\CoreBundle\EventListener\ImportExportTrait;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class PageImportExportSubscriber implements EventSubscriberInterface
{
use ImportExportTrait;
public function __construct(
private PageModel $pageModel,
private EntityManagerInterface $entityManager,
private AuditLogModel $auditLogModel,
private IpLookupHelper $ipLookupHelper,
private DenormalizerInterface $serializer,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntityExportEvent::class => ['onPageExport', 0],
EntityImportEvent::class => ['onPageImport', 0],
EntityImportUndoEvent::class => ['onUndoImport', 0],
EntityImportAnalyzeEvent::class => ['onDuplicationCheck', 0],
];
}
public function onPageExport(EntityExportEvent $event): void
{
if (Page::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$pageId = $event->getEntityId();
$page = $this->pageModel->getEntity($pageId);
if (!$page) {
return;
}
$pageData = [
'id' => $page->getId(),
'is_published' => $page->isPublished(),
'title' => $page->getTitle(),
'alias' => $page->getAlias(),
'template' => $page->getTemplate(),
'custom_html' => $page->getCustomHtml(),
'content' => $page->getContent(),
'publish_up' => $page->getPublishUp() ? $page->getPublishUp()->format(DATE_ATOM) : null,
'publish_down' => $page->getPublishDown() ? $page->getPublishDown()->format(DATE_ATOM) : null,
'hits' => $page->getHits(),
'unique_hits' => $page->getUniqueHits(),
'variant_hits' => $page->getVariantHits(),
'revision' => $page->getRevision(),
'meta_description' => $page->getMetaDescription(),
'head_script' => $page->getHeadScript(),
'footer_script' => $page->getFooterScript(),
'redirect_type' => $page->getRedirectType(),
'redirect_url' => $page->getRedirectUrl(),
'is_preference_center' => $page->getIsPreferenceCenter(),
'no_index' => $page->getNoIndex(),
'lang' => $page->getLanguage(),
'variant_settings' => $page->getVariantSettings(),
'uuid' => $page->getUuid(),
];
$event->addEntity(Page::ENTITY_NAME, $pageData);
$this->logAction('export', $page->getId(), $pageData);
}
public function onPageImport(EntityImportEvent $event): void
{
if (Page::ENTITY_NAME !== $event->getEntityName() || !$event->getEntityData()) {
return;
}
$stats = [
EntityImportEvent::NEW => ['names' => [], 'ids' => [], 'count' => 0],
EntityImportEvent::UPDATE => ['names' => [], 'ids' => [], 'count' => 0],
];
foreach ($event->getEntityData() as $element) {
$object = $this->entityManager->getRepository(Page::class)->findOneBy(['uuid' => $element['uuid']]);
$isNew = !$object;
$object ??= new Page();
$this->serializer->denormalize(
$element,
Page::class,
null,
['object_to_populate' => $object]
);
$this->pageModel->saveEntity($object);
$event->addEntityIdMap((int) $element['id'], $object->getId());
$status = $isNew ? EntityImportEvent::NEW : EntityImportEvent::UPDATE;
$stats[$status]['names'][] = $object->getTitle();
$stats[$status]['ids'][] = $object->getId();
++$stats[$status]['count'];
$this->logAction('import', $object->getId(), $element);
}
foreach ($stats as $status => $info) {
if ($info['count'] > 0) {
$event->setStatus($status, [Page::ENTITY_NAME => $info]);
}
}
}
public function onUndoImport(EntityImportUndoEvent $event): void
{
if (Page::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$summary = $event->getSummary();
if (!isset($summary['ids']) || empty($summary['ids'])) {
return;
}
foreach ($summary['ids'] as $id) {
$entity = $this->entityManager->getRepository(Page::class)->find($id);
if ($entity) {
$this->entityManager->remove($entity);
$this->logAction('undo_import', $id, ['deletedEntity' => Page::class]);
}
}
$this->entityManager->flush();
}
public function onDuplicationCheck(EntityImportAnalyzeEvent $event): void
{
$this->performDuplicationCheck(
$event,
Page::ENTITY_NAME,
Page::class,
'title',
$this->entityManager
);
}
/**
* @param array<string, mixed> $details
*/
private function logAction(string $action, int $objectId, array $details): void
{
$this->auditLogModel->writeToLog([
'bundle' => 'page',
'object' => 'page',
'objectId' => $objectId,
'action' => $action,
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
]);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\LanguageHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event as Events;
use Mautic\PageBundle\Event\PageEditSubmitEvent;
use Mautic\PageBundle\Event\PageEvent;
use Mautic\PageBundle\Model\PageDraftModel;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PageSubscriber implements EventSubscriberInterface
{
public function __construct(
private AssetsHelper $assetsHelper,
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
private LanguageHelper $languageHelper,
private PageModel $pageModel,
private PageDraftModel $pageDraftModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_POST_SAVE => ['onPagePostSave', 0],
PageEvents::PAGE_POST_DELETE => ['onPageDelete', 0],
PageEvents::PAGE_ON_DISPLAY => ['onPageDisplay', -255], // We want this to run last
PageEditSubmitEvent::class => ['managePageDraft'],
];
}
/**
* Add an entry to the audit log.
*/
public function onPagePostSave(PageEvent $event): void
{
$page = $event->getPage();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'page',
'object' => 'page',
'objectId' => $page->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
if (!array_key_exists($page->getLanguage(), $this->languageHelper->getSupportedLanguages())) {
$this->languageHelper->extractLanguagePackage($page->getLanguage());
}
}
/**
* Add a delete entry to the audit log.
*/
public function onPageDelete(PageEvent $event): void
{
$page = $event->getPage();
$log = [
'bundle' => 'page',
'object' => 'page',
'objectId' => $page->deletedId,
'action' => 'delete',
'details' => ['name' => $page->getTitle()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Allow event listeners to add scripts to
* - </head> : onPageDisplay_headClose
* - <body> : onPageDisplay_bodyOpen
* - </body> : onPageDisplay_bodyClose.
*/
public function onPageDisplay(Events\PageDisplayEvent $event): void
{
$content = $event->getContent();
// Get scripts to insert before </head>
ob_start();
$this->assetsHelper->outputScripts('onPageDisplay_headClose');
$headCloseScripts = ob_get_clean();
if ($headCloseScripts) {
$content = str_ireplace('</head>', $headCloseScripts."\n</head>", $content);
}
// Get scripts to insert after <body>
ob_start();
$this->assetsHelper->outputScripts('onPageDisplay_bodyOpen');
$bodyOpenScripts = ob_get_clean();
if ($bodyOpenScripts) {
preg_match('/(<body[^>]*>)/i', $content, $matches);
$content = str_ireplace($matches[0], $matches[0]."\n".$bodyOpenScripts, $content);
}
// Get scripts to insert before </body>
ob_start();
$this->assetsHelper->outputScripts('onPageDisplay_bodyClose');
$bodyCloseScripts = ob_get_clean();
if ($bodyCloseScripts) {
$content = str_ireplace('</body>', $bodyCloseScripts."\n</body>", $content);
}
// Get scripts to insert before a custom tag
$params = $event->getParams();
if (count($params) > 0) {
if (isset($params['custom_tag']) && $customTag = $params['custom_tag']) {
ob_start();
$this->assetsHelper->outputScripts('customTag');
$bodyCustomTag = ob_get_clean();
if ($bodyCustomTag) {
$content = str_ireplace($customTag, $bodyCustomTag."\n".$customTag, $content);
}
}
}
$event->setContent($content);
}
public function managePageDraft(PageEditSubmitEvent $event): void
{
$livePage = $event->getPreviousPage();
$editedPage = $event->getCurrentPage();
if (
($event->isSaveAndClose() || $event->isApply())
&& $editedPage->hasDraft()
) {
$pageDraft = $editedPage->getDraft();
$pageDraft->setHtml($editedPage->getCustomHtml());
$pageDraft->setTemplate($editedPage->getTemplate());
$editedPage->setCustomHtml($livePage->getCustomHtml());
$editedPage->setTemplate($livePage->getTemplate());
$this->pageDraftModel->saveDraft($pageDraft);
$this->pageModel->saveEntity($editedPage);
}
if ($event->isSaveAsDraft()) {
$pageDraft = $this
->pageDraftModel
->createDraft($editedPage, $editedPage->getCustomHtml(), $editedPage->getTemplate());
$editedPage->setCustomHtml($livePage->getCustomHtml());
$editedPage->setTemplate($livePage->getTemplate());
$editedPage->setDraft($pageDraft);
$this->pageModel->saveEntity($editedPage);
}
if ($event->isDiscardDraft()) {
$this->revertPageModifications($livePage, $editedPage);
$this->pageDraftModel->deleteDraft($editedPage);
$editedPage->setDraft(null);
$this->pageModel->saveEntity($editedPage);
}
if ($event->isApplyDraft()) {
$this->pageDraftModel->deleteDraft($editedPage);
$editedPage->setDraft(null);
}
}
public function deletePageDraft(PageEvent $event): void
{
try {
$this->pageDraftModel->deleteDraft($event->getPage());
} catch (NotFoundHttpException) {
// No associated draft found for deletion. We have nothing to do here. Return.
return;
}
}
private function revertPageModifications(Page $livePage, Page $editedPage): void
{
$livePageReflection = new \ReflectionObject($livePage);
$editedPageReflection = new \ReflectionObject($editedPage);
foreach ($livePageReflection->getProperties() as $property) {
if ('id' == $property->getName()) {
continue;
}
$property->setAccessible(true);
$name = $property->getName();
$value = $property->getValue($livePage);
$editedPageProperty = $editedPageReflection->getProperty($name);
$editedPageProperty->setAccessible(true);
$editedPageProperty->setValue($editedPage, $value);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\PageBundle\Event as Events;
use Mautic\PageBundle\Form\Type\PointActionPageHitType;
use Mautic\PageBundle\Form\Type\PointActionUrlHitType;
use Mautic\PageBundle\Helper\PointActionHelper;
use Mautic\PageBundle\PageEvents;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Model\PointModel;
use Mautic\PointBundle\PointEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PointSubscriber implements EventSubscriberInterface
{
public function __construct(
private PointModel $pointModel,
private PointActionHelper $pointActionHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
PointEvents::POINT_ON_BUILD => ['onPointBuild', 0],
PageEvents::PAGE_ON_HIT => ['onPageHit', 0],
];
}
public function onPointBuild(PointBuilderEvent $event): void
{
$action = [
'group' => 'mautic.page.point.action',
'label' => 'mautic.page.point.action.pagehit',
'description' => 'mautic.page.point.action.pagehit_descr',
'callback' => [PointActionHelper::class, 'validatePageHit'],
'formType' => PointActionPageHitType::class,
];
$event->addAction('page.hit', $action);
$action = [
'group' => 'mautic.page.point.action',
'label' => 'mautic.page.point.action.urlhit',
'description' => 'mautic.page.point.action.urlhit_descr',
'callback' => [$this->pointActionHelper, 'validateUrlHit'],
'formType' => PointActionUrlHitType::class,
'formTheme' => '@MauticPage/FormTheme/Point/pointaction_urlhit_widget.html.twig',
];
$event->addAction('url.hit', $action);
}
/**
* Trigger point actions for page hits.
*/
public function onPageHit(Events\PageHitEvent $event): void
{
if ($event->getPage()) {
// Mautic Landing Page was hit
$this->pointModel->triggerAction('page.hit', $event->getHit(), null, $event->getLead());
} else {
// Mautic Tracking Pixel was hit
$this->pointModel->triggerAction('url.hit', $event->getHit(), null, $event->getLead());
}
}
}

View File

@@ -0,0 +1,587 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\Chart\PieChart;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\LeadBundle\Report\DncReportService;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportDataEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\Event\ReportGraphEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_PAGES = 'pages';
public const CONTEXT_PAGE_HITS = 'page.hits';
public const CONTEXT_VIDEO_HITS = 'video.hits';
public function __construct(
private CompanyReportData $companyReportData,
private HitRepository $hitRepository,
private TranslatorInterface $translator,
private DncReportService $dncReportService,
) {
}
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', 0],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', 0],
ReportEvents::REPORT_ON_GRAPH_GENERATE => ['onReportGraphGenerate', 0],
ReportEvents::REPORT_ON_DISPLAY => ['onReportDisplay', 0],
];
}
/**
* Add available tables and columns to the report builder lookup.
*/
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_PAGES, self::CONTEXT_PAGE_HITS, self::CONTEXT_VIDEO_HITS])) {
return;
}
$prefix = 'p.';
$translationPrefix = 'tp.';
$variantPrefix = 'vp.';
$columns = [
$prefix.'title' => [
'label' => 'mautic.core.title',
'type' => 'string',
],
$prefix.'alias' => [
'label' => 'mautic.core.alias',
'type' => 'string',
],
$prefix.'revision' => [
'label' => 'mautic.page.report.revision',
'type' => 'string',
],
$prefix.'hits' => [
'label' => 'mautic.page.field.hits',
'type' => 'int',
],
$prefix.'unique_hits' => [
'label' => 'mautic.page.field.unique_hits',
'type' => 'int',
],
$translationPrefix.'id' => [
'label' => 'mautic.page.report.translation_parent_id',
'type' => 'int',
],
$translationPrefix.'title' => [
'label' => 'mautic.page.report.translation_parent_title',
'type' => 'string',
],
$variantPrefix.'id' => [
'label' => 'mautic.page.report.variant_parent_id',
'type' => 'string',
],
$variantPrefix.'title' => [
'label' => 'mautic.page.report.variant_parent_title',
'type' => 'string',
],
$prefix.'lang' => [
'label' => 'mautic.core.language',
'type' => 'string',
],
$prefix.'variant_start_date' => [
'label' => 'mautic.page.report.variant_start_date',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$prefix.'variant_start_date)',
],
$prefix.'variant_hits' => [
'label' => 'mautic.page.report.variant_hits',
'type' => 'int',
],
];
$columns = array_merge(
$columns,
$event->getStandardColumns('p.', ['name', 'description'], 'mautic_page_action'),
$event->getCategoryColumns()
);
$data = [
'display_name' => 'mautic.page.pages',
'columns' => $columns,
];
$event->addTable(self::CONTEXT_PAGES, $data);
if ($event->checkContext(self::CONTEXT_PAGE_HITS)) {
$hitPrefix = 'ph.';
$redirectHit = 'r.';
$hitColumns = [
$hitPrefix.'id' => [
'label' => 'mautic.page.report.hits.id',
'type' => 'int',
],
$hitPrefix.'date_hit' => [
'label' => 'mautic.page.report.hits.date_hit',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$hitPrefix.'date_hit)',
],
$hitPrefix.'date_left' => [
'label' => 'mautic.page.report.hits.date_left',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$hitPrefix.'date_left)',
],
$hitPrefix.'time_spent' => [
'label' => 'mautic.page.report.hits.time_spent',
'type' => 'string',
'formula' => 'IF('.$hitPrefix.'date_left IS NOT NULL, SEC_TO_TIME(TIMESTAMPDIFF(SECOND, '.$hitPrefix.'date_hit, '.$hitPrefix.'date_left)), \'\')',
],
$hitPrefix.'country' => [
'label' => 'mautic.page.report.hits.country',
'type' => 'string',
],
$hitPrefix.'region' => [
'label' => 'mautic.page.report.hits.region',
'type' => 'string',
],
$hitPrefix.'city' => [
'label' => 'mautic.page.report.hits.city',
'type' => 'string',
],
$hitPrefix.'isp' => [
'label' => 'mautic.page.report.hits.isp',
'type' => 'string',
],
$hitPrefix.'organization' => [
'label' => 'mautic.page.report.hits.organization',
'type' => 'string',
],
$hitPrefix.'code' => [
'label' => 'mautic.page.report.hits.code',
'type' => 'int',
],
$hitPrefix.'referer' => [
'label' => 'mautic.page.report.hits.referer',
'type' => 'string',
],
$hitPrefix.'url' => [
'label' => 'mautic.page.report.hits.url',
'type' => 'url',
],
$hitPrefix.'url_title' => [
'label' => 'mautic.page.report.hits.url_title',
'type' => 'string',
],
$hitPrefix.'user_agent' => [
'label' => 'mautic.page.report.hits.user_agent',
'type' => 'string',
],
$hitPrefix.'remote_host' => [
'label' => 'mautic.page.report.hits.remote_host',
'type' => 'string',
],
$hitPrefix.'browser_languages' => [
'label' => 'mautic.page.report.hits.browser_languages',
'type' => 'array',
],
$hitPrefix.'source' => [
'label' => 'mautic.report.field.source',
'type' => 'string',
],
$hitPrefix.'source_id' => [
'label' => 'mautic.report.field.source_id',
'type' => 'int',
],
$redirectHit.'url' => [
'label' => 'mautic.page.report.hits.redirect_url',
'type' => 'url',
],
$redirectHit.'hits' => [
'label' => 'mautic.page.report.hits.redirect_hit_count',
'type' => 'int',
],
$redirectHit.'unique_hits' => [
'label' => 'mautic.page.report.hits.redirect_unique_hits',
'type' => 'string',
],
'ds.device' => [
'label' => 'mautic.lead.device',
'type' => 'string',
],
'ds.device_brand' => [
'label' => 'mautic.lead.device_brand',
'type' => 'string',
],
'ds.device_model' => [
'label' => 'mautic.lead.device_model',
'type' => 'string',
],
'ds.device_os_name' => [
'label' => 'mautic.lead.device_os_name',
'type' => 'string',
],
'ds.device_os_shortname' => [
'label' => 'mautic.lead.device_os_shortname',
'type' => 'string',
],
'ds.device_os_version' => [
'label' => 'mautic.lead.device_os_version',
'type' => 'string',
],
'ds.device_os_platform' => [
'label' => 'mautic.lead.device_os_platform',
'type' => 'string',
],
];
$companyColumns = $this->companyReportData->getCompanyData();
$commonColumnsAndFilters = array_merge(
$columns,
$hitColumns,
$event->getCampaignByChannelColumns(),
$event->getLeadColumns(),
$event->getIpColumn(),
$companyColumns
);
$pageHitsColumns = array_merge($commonColumnsAndFilters, $this->dncReportService->getDncColumns());
$pageHitsFilters = array_merge($commonColumnsAndFilters, $this->dncReportService->getDncFilters());
$data = [
'display_name' => 'mautic.page.hits',
'columns' => $pageHitsColumns,
'filters' => $pageHitsFilters,
];
$event->addTable(self::CONTEXT_PAGE_HITS, $data, self::CONTEXT_PAGES);
// Register graphs
$context = self::CONTEXT_PAGE_HITS;
$event->addGraph($context, 'line', 'mautic.page.graph.line.hits');
$event->addGraph($context, 'line', 'mautic.page.graph.line.time.on.site');
$event->addGraph($context, 'pie', 'mautic.page.graph.pie.time.on.site', ['translate' => false]);
$event->addGraph($context, 'pie', 'mautic.page.graph.pie.new.vs.returning');
$event->addGraph($context, 'pie', 'mautic.page.graph.pie.devices');
$event->addGraph($context, 'pie', 'mautic.page.graph.pie.languages', ['translate' => false]);
$event->addGraph($context, 'table', 'mautic.page.table.referrers');
$event->addGraph($context, 'table', 'mautic.page.table.most.visited');
$event->addGraph($context, 'table', 'mautic.page.table.most.visited.unique');
}
if ($event->checkContext(self::CONTEXT_VIDEO_HITS)) {
$hitPrefix = 'vh.';
$hitColumns = [
$hitPrefix.'id' => [
'label' => 'mautic.core.id',
'type' => 'int',
],
$hitPrefix.'date_hit' => [
'label' => 'mautic.page.report.hits.date_hit',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$hitPrefix.'date_hit)',
],
$hitPrefix.'country' => [
'label' => 'mautic.page.report.hits.country',
'type' => 'string',
],
$hitPrefix.'region' => [
'label' => 'mautic.page.report.hits.region',
'type' => 'string',
],
$hitPrefix.'city' => [
'label' => 'mautic.page.report.hits.city',
'type' => 'string',
],
$hitPrefix.'isp' => [
'label' => 'mautic.page.report.hits.isp',
'type' => 'string',
],
$hitPrefix.'organization' => [
'label' => 'mautic.page.report.hits.organization',
'type' => 'string',
],
$hitPrefix.'code' => [
'label' => 'mautic.page.report.hits.code',
'type' => 'int',
],
$hitPrefix.'referer' => [
'label' => 'mautic.page.report.hits.referer',
'type' => 'string',
],
$hitPrefix.'url' => [
'label' => 'mautic.page.report.hits.url',
'type' => 'url',
],
$hitPrefix.'user_agent' => [
'label' => 'mautic.page.report.hits.user_agent',
'type' => 'string',
],
$hitPrefix.'remote_host' => [
'label' => 'mautic.page.report.hits.remote_host',
'type' => 'string',
],
$hitPrefix.'browser_languages' => [
'label' => 'mautic.page.report.hits.browser_languages',
'type' => 'array',
],
$hitPrefix.'channel' => [
'label' => 'mautic.report.field.source',
'type' => 'string',
],
$hitPrefix.'channel_id' => [
'label' => 'mautic.report.field.source_id',
'type' => 'int',
],
'time_watched' => [
'label' => 'mautic.page.report.hits.time_watched',
'type' => 'string',
'formula' => 'if('.$hitPrefix.'duration = 0,\'-\',SEC_TO_TIME('.$hitPrefix.'time_watched))',
],
'duration' => [
'label' => 'mautic.page.report.hits.duration',
'type' => 'string',
'formula' => 'if('.$hitPrefix.'duration = 0,\'-\',SEC_TO_TIME('.$hitPrefix.'duration))',
],
];
$data = [
'display_name' => 'mautic.'.self::CONTEXT_VIDEO_HITS,
'columns' => array_merge($hitColumns, $event->getLeadColumns(), $event->getIpColumn()),
];
$event->addTable(self::CONTEXT_VIDEO_HITS, $data, 'videos');
}
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGenerate(ReportGeneratorEvent $event): void
{
$context = $event->getContext();
$qb = $event->getQueryBuilder();
$hasGroupBy = $event->hasGroupBy();
switch ($context) {
case self::CONTEXT_PAGES:
$qb->from(MAUTIC_TABLE_PREFIX.'pages', 'p')
->leftJoin('p', MAUTIC_TABLE_PREFIX.'pages', 'tp', 'p.id = tp.id')
->leftJoin('p', MAUTIC_TABLE_PREFIX.'pages', 'vp', 'p.id = vp.id');
$event->addCategoryLeftJoin($qb, 'p');
break;
case self::CONTEXT_PAGE_HITS:
$event->applyDateFiltersWithoutNullValues($qb, 'date_hit', 'ph');
$qb->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->leftJoin('ph', MAUTIC_TABLE_PREFIX.'pages', 'p', 'ph.page_id = p.id')
->leftJoin('p', MAUTIC_TABLE_PREFIX.'pages', 'tp', 'p.id = tp.id')
->leftJoin('p', MAUTIC_TABLE_PREFIX.'pages', 'vp', 'p.id = vp.id')
->leftJoin('ph', MAUTIC_TABLE_PREFIX.'page_redirects', 'r', 'r.id = ph.redirect_id')
->leftJoin('ph', MAUTIC_TABLE_PREFIX.'lead_devices', 'ds', 'ds.id = ph.device_id');
$event->addIpAddressLeftJoin($qb, 'ph');
$event->addCategoryLeftJoin($qb, 'p');
$event->addLeadLeftJoin($qb, 'ph');
$event->addCampaignByChannelJoin($qb, 'p', 'page');
if ($this->companyReportData->eventHasCompanyColumns($event)) {
$event->addCompanyLeftJoin($qb);
}
break;
case 'video.hits':
if (!$hasGroupBy) {
$qb->groupBy('vh.id');
}
$event->applyDateFilters($qb, 'date_hit', 'vh');
$qb->from(MAUTIC_TABLE_PREFIX.'video_hits', 'vh');
$event->addIpAddressLeftJoin($qb, 'vh');
$event->addLeadLeftJoin($qb, 'vh');
break;
}
$event->setQueryBuilder($qb);
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGraphGenerate(ReportGraphEvent $event): void
{
// Context check, we only want to fire for Lead reports
if (!$event->checkContext(self::CONTEXT_PAGE_HITS)) {
return;
}
$graphs = $event->getRequestedGraphs();
$qb = $event->getQueryBuilder();
foreach ($graphs as $g) {
$options = $event->getOptions($g);
$queryBuilder = clone $qb;
/** @var ChartQuery $chartQuery */
$chartQuery = clone $options['chartQuery'];
$chartQuery->applyDateFilters($queryBuilder, 'date_hit', 'ph');
switch ($g) {
case 'mautic.page.graph.line.hits':
$chart = new LineChart(null, $options['dateFrom'], $options['dateTo']);
$chartQuery->modifyTimeDataQuery($queryBuilder, 'date_hit', 'ph');
$hits = $chartQuery->loadAndBuildTimeData($queryBuilder);
$chart->setDataset($options['translator']->trans($g), $hits);
$data = $chart->render();
$data['name'] = $g;
$event->setGraph($g, $data);
break;
case 'mautic.page.graph.line.time.on.site':
$chart = new LineChart(null, $options['dateFrom'], $options['dateTo']);
$queryBuilder->select('TIMESTAMPDIFF(SECOND, ph.date_hit, ph.date_left) as data, ph.date_hit as date');
$queryBuilder->andWhere($qb->expr()->isNotNull('ph.date_left'));
$hits = $chartQuery->loadAndBuildTimeData($queryBuilder);
$chart->setDataset($options['translator']->trans($g), $hits);
$data = $chart->render();
$data['name'] = $g;
$event->setGraph($g, $data);
break;
case 'mautic.page.graph.pie.time.on.site':
$timesOnSite = $this->hitRepository->getDwellTimeLabels();
$chart = new PieChart();
foreach ($timesOnSite as $time) {
$q = clone $queryBuilder;
$chartQuery->modifyCountDateDiffQuery($q, 'date_hit', 'date_left', $time['from'], $time['till'], 'ph');
$data = $chartQuery->fetchCountDateDiff($q);
$chart->setDataset($time['label'], $data);
}
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-time-line',
]
);
break;
case 'mautic.page.graph.pie.new.vs.returning':
$chart = new PieChart();
$allQ = clone $queryBuilder;
$uniqueQ = clone $queryBuilder;
$chartQuery->modifyCountQuery($allQ, 'date_hit', [], 'ph');
$chartQuery->modifyCountQuery($uniqueQ, 'date_hit', ['getUnique' => true, 'selectAlso' => ['ph.page_id']], 'ph');
$all = $chartQuery->fetchCount($allQ);
$unique = $chartQuery->fetchCount($uniqueQ);
$returning = $all - $unique;
$chart->setDataset($this->translator->trans('mautic.page.unique'), $unique);
$chart->setDataset($this->translator->trans('mautic.page.graph.pie.new.vs.returning.returning'), $returning);
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-information-2-line',
]
);
break;
case 'mautic.page.graph.pie.languages':
$queryBuilder->select('ph.page_language, COUNT(distinct(ph.id)) as the_count')
->groupBy('ph.page_language')
->andWhere($qb->expr()->isNotNull('ph.page_language'));
$data = $queryBuilder->executeQuery()->fetchAllAssociative();
$chart = new PieChart();
foreach ($data as $lang) {
$chart->setDataset($lang['page_language'], $lang['the_count']);
}
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-earth-line',
]
);
break;
case 'mautic.page.graph.pie.devices':
$queryBuilder->select('ds.device, COUNT(distinct(ph.id)) as the_count')
->groupBy('ds.device');
$data = $queryBuilder->executeQuery()->fetchAllAssociative();
$chart = new PieChart();
foreach ($data as $device) {
$label = substr(empty($device['device']) ? $this->translator->trans('mautic.core.no.info') : $device['device'], 0, 12);
$chart->setDataset($label, $device['the_count']);
}
$event->setGraph(
$g,
[
'data' => $chart->render(),
'name' => $g,
'iconClass' => 'ri-earth-line',
]
);
break;
case 'mautic.page.table.referrers':
$limit = 10;
$offset = 0;
$items = $this->hitRepository->getReferers($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-login-box-line';
$event->setGraph($g, $graphData);
break;
case 'mautic.page.table.most.visited':
$limit = 10;
$offset = 0;
$items = $this->hitRepository->getMostVisited($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-eye-line';
$graphData['link'] = 'mautic_page_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.page.table.most.visited.unique':
$limit = 10;
$offset = 0;
$items = $this->hitRepository->getMostVisited($queryBuilder, $limit, $offset, 'p.unique_hits', 'sessions');
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-eye-line';
$graphData['link'] = 'mautic_page_action';
$event->setGraph($g, $graphData);
break;
}
unset($queryBuilder);
}
}
public function onReportDisplay(ReportDataEvent $event): void
{
$data = $event->getData();
if ($event->checkContext([self::CONTEXT_PAGE_HITS])) {
$data = $this->dncReportService->processDncStatusDisplay($data);
}
$event->setData($data);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private PageModel $pageModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearch(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->pageModel,
'@MauticPage/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.page.pages', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
if ($this->security->isGranted(['page:pages:viewown', 'page:pages:viewother'], 'MATCH_ONE')) {
$event->addCommands(
'mautic.page.pages',
$this->pageModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Entity\VideoHit;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
$this->addContactRestrictedRepositories(
[
Hit::class,
VideoHit::class,
]
);
$this->repositories[] = $entityManager->getRepository(Redirect::class);
$this->repositories[] = $entityManager->getRepository(Trackable::class);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class TokenSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_DISPLAY => ['decodeTokens', 254],
];
}
public function decodeTokens(PageDisplayEvent $event): void
{
// Find and replace encoded tokens for trackable URL conversion
$content = $event->getContent();
$content = preg_replace('/(%7B)(.*?)(%7D)/i', '{$2}', $content);
$event->setContent($content);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Mautic\PageBundle\EventListener;
use Mautic\PageBundle\Event\PageHitEvent;
use Mautic\PageBundle\PageEvents;
use Mautic\WebhookBundle\Event\WebhookBuilderEvent;
use Mautic\WebhookBundle\Model\WebhookModel;
use Mautic\WebhookBundle\WebhookEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class WebhookSubscriber implements EventSubscriberInterface
{
public function __construct(
private WebhookModel $webhookModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
WebhookEvents::WEBHOOK_ON_BUILD => ['onWebhookBuild', 0],
PageEvents::PAGE_ON_HIT => ['onPageHit', 0],
];
}
/**
* Add event triggers and actions.
*/
public function onWebhookBuild(WebhookBuilderEvent $event): void
{
// add checkbox to the webhook form for new leads
$pageHit = [
'label' => 'mautic.page.webhook.event.hit',
'description' => 'mautic.page.webhook.event.hit_desc',
];
// add it to the list
$event->addEvent(PageEvents::PAGE_ON_HIT, $pageHit);
}
public function onPageHit(PageHitEvent $event): void
{
$this->webhookModel->queueWebhooksByType(
PageEvents::PAGE_ON_HIT,
[
'hit' => $event->getHit(),
],
[
'hitDetails',
'emailDetails',
'pageList',
'leadList',
]
);
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\PageBundle\Exception;
class InvalidRenderedHtmlException extends \Exception
{
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class AbTestPropertiesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$options = ['label' => false];
if (isset($options['formTypeOptions'])) {
$options = array_merge($options, $options['formTypeOptions']);
}
$builder->add('properties', $options['formType'], $options);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined([
'formType',
'formTypeOptions',
]);
}
public function getBlockPrefix(): string
{
return 'page_abtest_settings';
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class CampaignEventPageHitType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('pages', PageListType::class, [
'label' => 'mautic.page.campaign.event.form.pages',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.campaign.event.form.pages.descr',
],
]);
$builder->add('url', TextType::class, [
'label' => 'mautic.page.campaign.event.form.url',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.campaign.event.form.url.descr',
],
]);
$builder->add('referer', TextType::class, [
'label' => 'mautic.page.campaign.event.form.referer',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.campaign.event.form.referer.descr',
],
]);
}
public function getBlockPrefix(): string
{
return 'campaignevent_pagehit';
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class ConfigTrackingPageType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'anonymize_ip',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.anonymize_ip',
'data' => isset($options['data']['anonymize_ip']) && (bool) $options['data']['anonymize_ip'],
'attr' => [
'tooltip' => 'mautic.page.config.form.anonymize_ip.tooltip',
'onchange' => 'Mautic.showAnonymizeWarningMessage(this)',
],
]
);
$builder->add(
'track_contact_by_ip',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.track_contact_by_ip',
'data' => isset($options['data']['track_contact_by_ip']) && (bool) $options['data']['track_contact_by_ip'],
'attr' => [
'tooltip' => 'mautic.page.config.form.track_contact_by_ip.tooltip',
'data-enable-on' => '{"config_trackingconfig_anonymize_ip_0":"checked"}',
],
]
);
$builder->add(
'do_not_track_404_anonymous',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.do_not_track_404_anonymous',
'data' => isset($options['data']['do_not_track_404_anonymous']) && (bool) $options['data']['do_not_track_404_anonymous'],
'attr' => [
'tooltip' => 'mautic.page.config.form.do_not_track_404_anonymous.tooltip',
],
]
);
$builder->add(
'facebook_pixel_id',
TextType::class,
[
'label' => 'mautic.page.config.form.facebook.pixel.id',
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'facebook_pixel_trackingpage_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.tracking.trackingpage.enabled',
'data' => isset($options['data']['facebook_pixel_trackingpage_enabled']) && (bool) $options['data']['facebook_pixel_trackingpage_enabled'],
]
);
$builder->add(
'facebook_pixel_landingpage_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.tracking.landingpage.enabled',
'data' => isset($options['data']['facebook_pixel_landingpage_enabled']) && (bool) $options['data']['facebook_pixel_landingpage_enabled'],
]
);
$builder->add(
'google_analytics_id',
TextType::class,
[
'label' => 'mautic.page.config.form.google.analytics.id',
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'google_analytics_trackingpage_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.tracking.trackingpage.enabled',
'data' => isset($options['data']['google_analytics_trackingpage_enabled']) && (bool) $options['data']['google_analytics_trackingpage_enabled'],
]
);
$builder->add(
'google_analytics_landingpage_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.tracking.landingpage.enabled',
'data' => isset($options['data']['google_analytics_landingpage_enabled']) && (bool) $options['data']['google_analytics_landingpage_enabled'],
]
);
$builder->add(
'google_analytics_anonymize_ip',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.tracking.anonymize.ip.enabled',
'data' => isset($options['data']['google_analytics_anonymize_ip']) && (bool) $options['data']['google_analytics_anonymize_ip'],
'attr' => [
'tooltip' => 'mautic.page.config.form.tracking.anonymize.ip.enabled.tooltip',
],
]
);
}
public function getBlockPrefix(): string
{
return 'trackingconfig';
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class ConfigType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'cat_in_page_url',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.form.cat.in.url',
'data' => (bool) $options['data']['cat_in_page_url'],
'attr' => [
'tooltip' => 'mautic.page.config.form.cat.in.url.tooltip',
],
]
);
$builder->add(
'google_analytics',
TextareaType::class,
[
'label' => 'mautic.page.config.form.google.analytics',
'label_attr' => ['class' => 'control-label'],
'data' => htmlspecialchars_decode((string) $options['data']['google_analytics']),
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.config.form.google.analytics.tooltip',
'rows' => 10,
],
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'pageconfig';
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class DashboardHitsInTimeWidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('flag', ChoiceType::class, [
'label' => 'mautic.page.visit.flag.filter',
'choices' => [
'mautic.page.show.total.visits' => '',
'mautic.page.show.unique.visits' => 'unique',
'mautic.page.show.unique.and.total.visits' => 'total_and_unique',
],
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'empty_data' => '',
'required' => false,
]
);
}
public function getBlockPrefix(): string
{
return 'page_dashboard_hits_in_time_widget';
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Model\PageModel;
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<mixed>>
*/
class PageListType extends AbstractType
{
/**
* @var bool
*/
private $canViewOther = false;
public function __construct(
private PageModel $model,
CorePermissions $corePermissions,
) {
$this->canViewOther = $corePermissions->isGranted('page:pages:viewother');
}
public function configureOptions(OptionsResolver $resolver): void
{
$model = $this->model;
$canViewOther = $this->canViewOther;
$resolver->setDefaults(
[
'choices' => function (Options $options) use ($model, $canViewOther): array {
$choices = [];
$publishedOnly = $options['published_only'] ?? false;
$pages = $model->getRepository()->getPageList('', 0, 0, $canViewOther, $options['top_level'], $options['ignore_ids'], [], $publishedOnly);
foreach ($pages as $page) {
$choices[$page['language']]["{$page['title']} ({$page['id']})"] = $page['id'];
}
// sort by language
ksort($choices);
foreach ($choices as &$pages) {
ksort($pages);
}
return $choices;
},
'placeholder' => false,
'expanded' => false,
'multiple' => true,
'required' => false,
'top_level' => 'variant',
'ignore_ids' => [],
]
);
$resolver->setDefined(['top_level', 'ignore_ids', 'published_only']);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,410 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Doctrine\ORM\EntityManager;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\DataTransformer\IdToEntityModelTransformer;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\ThemeListType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Helper\ThemeHelperInterface;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Helper\PageConfigInterface;
use Mautic\PageBundle\Model\PageModel;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
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\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<Page>
*/
class PageType extends AbstractType
{
private ?\Mautic\UserBundle\Entity\User $user;
/**
* @var bool
*/
private $canViewOther = false;
public function __construct(
private EntityManager $em,
private PageModel $model,
CorePermissions $corePermissions,
UserHelper $userHelper,
private ThemeHelperInterface $themeHelper,
private PageConfigInterface $pageConfig,
) {
$this->canViewOther = $corePermissions->isGranted('page:pages:viewother');
$this->user = $userHelper->getUser();
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['content' => 'html', 'customHtml' => 'html', 'redirectUrl' => 'url', 'headScript' => 'html', 'footerScript' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('page.page', $options));
$builder->add(
'title',
TextType::class,
[
'label' => 'mautic.core.title',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$html = $options['data']->getCustomHtml();
if ($this->pageConfig->isDraftEnabled() && !empty($options['data']->getId()) && $options['data']->hasDraft() && !empty($options['data']->getDraft()->getHtml())) {
$html = $options['data']->getDraft()->getHtml();
}
$builder->add(
'customHtml',
TextareaType::class,
[
'label' => 'mautic.page.form.customhtml',
'required' => false,
'attr' => [
'tooltip' => 'mautic.page.form.customhtml.help',
'class' => 'form-control editor-builder-tokens builder-html',
'data-token-callback' => 'page:getBuilderTokens',
'data-token-activator' => '{',
'rows' => '25',
],
'data' => $html,
]
);
$template = $options['data']->getTemplate() ?? 'blank';
// If theme does not exist, set empty
$template = $this->themeHelper->getCurrentTheme($template, 'page');
if ($this->pageConfig->isDraftEnabled() && !empty($options['data']->getId()) && $options['data']->hasDraft() && !empty($options['data']->getDraft()->getTemplate())) {
$template = $options['data']->getDraft()->getTemplate();
}
$builder->add(
'template',
ThemeListType::class,
[
'feature' => 'page',
'attr' => [
'class' => 'form-control not-chosen hidden',
'tooltip' => 'mautic.page.form.template.help',
],
'placeholder' => 'mautic.core.none',
'data' => $template,
]
);
$builder->add('isPublished', YesNoButtonGroupType::class, [
'label' => 'mautic.core.form.available',
]);
$builder->add(
'isPreferenceCenter',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.form.preference_center',
'data' => $options['data']->isPreferenceCenter() ?: false,
'attr' => [
'tooltip' => 'mautic.page.form.preference_center.tooltip',
],
]
);
$builder->add(
'noIndex',
YesNoButtonGroupType::class,
[
'label' => 'mautic.page.config.no_index',
'data' => $options['data']->getNoIndex() ?: false,
]
);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add('sessionId', HiddenType::class);
// Custom field for redirect URL
$this->model->getRepository()->setCurrentUser($this->user);
$redirectUrlDataOptions = '';
$pages = $this->model->getRepository()->getPageList('', 0, 0, $this->canViewOther, 'variant', [$options['data']->getId()]);
foreach ($pages as $page) {
$redirectUrlDataOptions .= "|{$page['alias']}";
}
$transformer = new IdToEntityModelTransformer($this->em, Page::class);
$builder->add(
$builder->create(
'variantParent',
HiddenType::class
)->addModelTransformer($transformer)
);
$builder->add(
$builder->create(
'translationParent',
PageListType::class,
[
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'ignore_ids' => [(int) $options['data']->getId()],
]
)->addModelTransformer($transformer)
);
$formModifier = function (FormInterface $form, $isVariant): void {
if ($isVariant) {
$form->add(
'variantSettings',
VariantType::class,
[
'label' => false,
]
);
}
};
// Building the form
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier): void {
$formModifier(
$event->getForm(),
$event->getData()->getVariantParent()
);
}
);
// After submit
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($formModifier): void {
$data = $event->getData();
if (isset($data['variantParent'])) {
$formModifier(
$event->getForm(),
$data['variantParent']
);
}
}
);
$builder->add(
'metaDescription',
TextareaType::class,
[
'label' => 'mautic.page.form.metadescription',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control', 'maxlength' => 160],
'required' => false,
]
);
$builder->add(
'headScript',
TextareaType::class,
[
'label' => 'mautic.page.form.headscript',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'rows' => '8',
'tooltip' => 'mautic.page.form.script.help',
],
'required' => false,
]
);
$builder->add(
'footerScript',
TextareaType::class,
[
'label' => 'mautic.page.form.footerscript',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'rows' => '8',
'tooltip' => 'mautic.page.form.script.help',
],
'required' => false,
]
);
$builder->add(
'redirectType',
RedirectListType::class,
[
'feature' => 'page',
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.form.redirecttype.help',
],
'placeholder' => 'mautic.page.form.redirecttype.none',
]
);
$builder->add(
'redirectUrl',
UrlType::class,
[
'required' => true,
'label' => 'mautic.page.form.redirecturl',
'label_attr' => [
'class' => 'control-label',
],
'attr' => [
'class' => 'form-control',
'maxlength' => 200,
'tooltip' => 'mautic.page.form.redirecturl.help',
'data-hide-on' => '{"page_redirectType":""}',
'data-toggle' => 'field-lookup',
'data-action' => 'page:fieldList',
'data-target' => 'redirectUrl',
'data-options' => $redirectUrlDataOptions,
],
'default_protocol' => null,
]
);
$builder->add(
'alias',
TextType::class,
[
'label' => 'mautic.core.alias',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.help.alias',
],
'required' => false,
]
);
// add category
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'page',
]
);
$builder->add('projects', ProjectType::class);
$builder->add(
'language',
LocaleType::class,
[
'label' => 'mautic.core.language',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.form.language.help',
],
'required' => true,
]
);
$extraButtons['pre_extra_buttons'] = [
[
'name' => 'builder',
'label' => 'mautic.core.builder',
'attr' => [
'class' => 'btn btn-tertiary btn-dnd btn-nospin btn-builder text-interactive',
'icon' => 'ri-layout-line',
'onclick' => "Mautic.launchBuilder('page');",
],
],
];
$draftActionButtons = $this->getDraftActionButtons($options['data']);
if (!empty($draftActionButtons)) {
$extraButtons['post_extra_buttons'] = $draftActionButtons;
}
$builder->add('buttons',
FormButtonsType::class,
$extraButtons
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
/**
* @return array<mixed>
*/
private function getDraftActionButtons(Page $page): array
{
$draftActionButtons = [];
if (!$this->pageConfig->isDraftEnabled() || empty($page->getId())) {
return $draftActionButtons;
}
if ($page->hasDraft()) {
$draftActionButtons[] = [
'name' => 'apply_draft',
'label' => 'mautic.core.applydraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-apply-draft btn-copy',
'icon' => 'fa fa-files-o text-success',
],
];
$draftActionButtons[] = [
'name' => 'discard_draft',
'label' => 'mautic.core.discarddraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-apply-draft btn-copy',
'icon' => 'fa fa-trash text-danger',
],
];
} else {
$draftActionButtons[] = [
'name' => 'save_draft',
'label' => 'mautic.core.saveasdraft',
'type' => SubmitType::class,
'attr' => [
'class' => 'btn btn-primary btn-default text-primary btn-save-draft',
'icon' => 'fa fa-file text-success',
],
];
}
return $draftActionButtons;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Page::class,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class PointActionPageHitType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('pages', PageListType::class, [
'label' => 'mautic.page.point.action.form.pages',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.pages.descr',
],
]);
}
public function getBlockPrefix(): string
{
return 'pointaction_pagehit';
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\CoreBundle\Form\DataTransformer\SecondsConversionTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class PointActionUrlHitType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('page_url', TextType::class, [
'label' => 'mautic.page.point.action.form.page.url',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.page.url.descr',
'placeholder' => 'http://',
],
]);
$builder->add('page_hits', IntegerType::class, [
'label' => 'mautic.page.hits',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.page.hits.descr',
],
]);
$formModifier = function (FormInterface $form, $data) use ($builder): void {
$unit = $data['accumulative_time_unit'] ?? 'H';
$form->add('accumulative_time_unit', HiddenType::class, [
'data' => $unit,
]);
$secondsTransformer = new SecondsConversionTransformer($unit);
$form->add(
$builder->create('accumulative_time', TextType::class, [
'label' => 'mautic.page.point.action.form.accumulative.time',
'required' => false,
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.accumulative.time.descr',
],
'auto_initialize' => false,
])
->addViewTransformer($secondsTransformer)
->getForm()
);
$unit = $data['returns_within_unit'] ?? 'H';
$secondsTransformer = new SecondsConversionTransformer($unit);
$form->add('returns_within_unit', HiddenType::class, [
'data' => $unit,
]);
$form->add(
$builder->create('returns_within', TextType::class, [
'label' => 'mautic.page.point.action.form.returns.within',
'required' => false,
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.returns.within.descr',
'onBlur' => 'Mautic.EnablesOption(this.id)',
],
'auto_initialize' => false,
])
->addViewTransformer($secondsTransformer)
->getForm()
);
$unit = $data['returns_after_unit'] ?? 'H';
$secondsTransformer = new SecondsConversionTransformer($unit);
$form->add('returns_after_unit', HiddenType::class, [
'data' => $unit,
]);
$form->add(
$builder->create('returns_after', TextType::class, [
'label' => 'mautic.page.point.action.form.returns.after',
'required' => false,
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.point.action.form.returns.after.descr',
'onBlur' => 'Mautic.EnablesOption(this.id)',
],
'auto_initialize' => false,
])
->addViewTransformer($secondsTransformer)
->getForm()
);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier): void {
$data = $event->getData();
$formModifier($event->getForm(), $data);
}
);
$builder->addEventListener(FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($formModifier): void {
$data = $event->getData();
$formModifier($event->getForm(), $data);
}
);
}
public function getBlockPrefix(): string
{
return 'pointaction_urlhit';
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Model\PageModel;
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<Page>
*/
class PreferenceCenterListType extends AbstractType
{
/**
* @var bool
*/
private $canViewOther = false;
public function __construct(
private PageModel $model,
CorePermissions $corePermissions,
) {
$this->canViewOther = $corePermissions->isGranted('page:pages:viewother');
}
public function configureOptions(OptionsResolver $resolver): void
{
$model = $this->model;
$canViewOther = $this->canViewOther;
$resolver->setDefaults(
[
'choices' => function (Options $options) use ($model, $canViewOther): array {
$choices = [];
$pages = $model->getRepository()->getPageList('', 0, 0, $canViewOther, $options['top_level'], $options['ignore_ids'], ['isPreferenceCenter']);
foreach ($pages as $page) {
if ($page['isPreferenceCenter']) {
$choices[$page['language']]["{$page['title']} ({$page['id']})"] = $page['id'];
}
}
// sort by language
ksort($choices);
foreach ($choices as &$pages) {
ksort($pages);
}
return $choices;
},
'placeholder' => false,
'expanded' => false,
'multiple' => true,
'required' => false,
'top_level' => 'variant',
'ignore_ids' => [],
]
);
$resolver->setDefined(['top_level', 'ignore_ids']);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class RedirectListType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$choices = [
'mautic.page.form.redirecttype.permanent' => Response::HTTP_MOVED_PERMANENTLY,
'mautic.page.form.redirecttype.temporary' => Response::HTTP_FOUND,
'mautic.page.form.redirecttype.303_temporary' => Response::HTTP_SEE_OTHER,
'mautic.page.form.redirecttype.307_temporary' => Response::HTTP_TEMPORARY_REDIRECT,
'mautic.page.form.redirecttype.308_permanent' => Response::HTTP_PERMANENTLY_REDIRECT,
];
$resolver->setDefaults([
'choices' => $choices,
'expanded' => false,
'multiple' => false,
'label' => 'mautic.page.form.redirecttype',
'label_attr' => ['class' => 'control-label'],
'placeholder' => false,
'required' => false,
'attr' => ['class' => 'form-control'],
'feature' => 'all',
]);
$resolver->setDefined(['feature']);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\PageBundle\Helper\TrackingHelper;
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\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array<mixed>>
*/
class TrackingPixelSendType extends AbstractType
{
public function __construct(
protected TrackingHelper $trackingHelper,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$trackingServices = $this->trackingHelper->getEnabledServices();
$builder->add('services', ChoiceType::class, [
'label' => 'mautic.page.tracking.form.services',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'expanded' => false,
'multiple' => true,
'choices' => array_flip($trackingServices),
'placeholder' => 'mautic.core.form.chooseone',
'constraints' => [
new NotBlank(
['message' => 'mautic.core.ab_test.winner_criteria.not_blank']
),
],
]);
$builder->add(
'category',
TextType::class,
[
'label' => 'mautic.page.tracking.form.category',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.page.tracking.form.category.tooltip',
],
'required' => true,
'constraints' => [
new NotBlank(),
],
]
);
$builder->add(
'action',
TextType::class,
[
'label' => 'mautic.page.tracking.form.action',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => true,
'constraints' => [
new NotBlank(),
],
]
);
$builder->add(
'label',
TextType::class,
[
'label' => 'mautic.page.tracking.form.label',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => true,
'constraints' => [
new NotBlank(),
],
]
);
}
public function getBlockPrefix(): string
{
return 'tracking_pixel_send_action';
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Mautic\PageBundle\Form\Type;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class VariantType extends AbstractType
{
public function __construct(
private PageModel $pageModel,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'weight',
IntegerType::class, [
'label' => 'mautic.core.ab_test.form.traffic_weight',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.ab_test.form.traffic_weight.help',
],
'constraints' => [
new NotBlank(
['message' => 'mautic.page.variant.weight.notblank']
),
],
]
);
$abTestWinnerCriteria = $this->pageModel->getBuilderComponents(null, 'abTestWinnerCriteria');
if (!empty($abTestWinnerCriteria)) {
$criteria = $abTestWinnerCriteria['criteria'];
$choices = $abTestWinnerCriteria['choices'];
$builder->add(
'winnerCriteria',
ChoiceType::class, [
'label' => 'mautic.core.ab_test.form.winner',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.getAbTestWinnerForm(\'page\', \'page\', this);',
],
'expanded' => false,
'multiple' => false,
'choices' => $choices,
'placeholder' => 'mautic.core.form.chooseone',
'constraints' => [
new NotBlank(
['message' => 'mautic.core.ab_test.winner_criteria.not_blank']
),
],
]
);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($criteria): void {
$form = $event->getForm();
$data = $event->getData();
if (isset($data['winnerCriteria'])) {
if (!empty($criteria[$data['winnerCriteria']]['formType'])) {
$formTypeOptions = [
'required' => false,
'label' => false,
];
if (!empty($criteria[$data]['formTypeOptions'])) {
$formTypeOptions = array_merge($formTypeOptions, $criteria[$data]['formTypeOptions']);
}
$form->add('properties', $criteria[$data]['formType'], $formTypeOptions);
}
}
});
}
}
public function getBlockPrefix(): string
{
return 'pagevariant';
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Helper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
final class PageConfig implements PageConfigInterface
{
public function __construct(private CoreParametersHelper $coreParametersHelper)
{
}
public function isDraftEnabled(): bool
{
return (bool) $this->coreParametersHelper->get('page_draft_enabled', false);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Helper;
interface PageConfigInterface
{
public function isDraftEnabled(): bool;
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Mautic\PageBundle\Helper;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Page;
class PointActionHelper
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
public static function validatePageHit($eventDetails, $action): bool
{
$pageHit = $eventDetails->getPage();
if ($pageHit instanceof Page) {
[$parent, $children] = $pageHit->getVariants();
// use the parent (self or configured parent)
$pageHitId = $parent->getId();
} else {
$pageHitId = 0;
}
// If no pages are selected, the pages array does not exist
if (isset($action['properties']['pages'])) {
$limitToPages = $action['properties']['pages'];
}
if (!empty($limitToPages) && !in_array($pageHitId, $limitToPages)) {
// no points change
return false;
}
return true;
}
public function validateUrlHit($eventDetails, $action): bool
{
$changePoints = [];
$url = $eventDetails->getUrl();
$limitToUrl = html_entity_decode(trim($action['properties']['page_url']));
if (!$limitToUrl || !fnmatch($limitToUrl, $url)) {
// no points change
return false;
}
$hitRepository = $this->entityManager->getRepository(Hit::class);
$lead = $eventDetails->getLead();
$urlWithSqlWC = str_replace('*', '%', $limitToUrl);
if (isset($action['properties']['first_time']) && true === $action['properties']['first_time']) {
$hitStats = $hitRepository->getDwellTimesForUrl($urlWithSqlWC, ['leadId' => $lead->getId()]);
if (isset($hitStats['count']) && $hitStats['count']) {
$changePoints['first_time'] = false;
} else {
$changePoints['first_time'] = true;
}
}
$now = new \DateTime();
if ($action['properties']['returns_within'] || $action['properties']['returns_after']) {
// get the latest hit only when it's needed
$latestHit = $hitRepository->getLatestHit(['leadId' => $lead->getId(), $urlWithSqlWC, 'second_to_last' => $eventDetails->getId()]);
} else {
$latestHit = null;
}
if ($action['properties']['accumulative_time']) {
if (!isset($hitStats)) {
$hitStats = $hitRepository->getDwellTimesForUrl($urlWithSqlWC, ['leadId' => $lead->getId()]);
}
if (isset($hitStats['sum'])) {
if ($action['properties']['accumulative_time'] <= $hitStats['sum']) {
$changePoints['accumulative_time'] = true;
} else {
$changePoints['accumulative_time'] = false;
}
} else {
$changePoints['accumulative_time'] = false;
}
}
if ($action['properties']['page_hits']) {
if (!isset($hitStats)) {
$hitStats = $hitRepository->getDwellTimesForUrl($urlWithSqlWC, ['leadId' => $lead->getId()]);
}
if (isset($hitStats['count']) && $hitStats['count'] >= $action['properties']['page_hits']) {
$changePoints['page_hits'] = true;
} else {
$changePoints['page_hits'] = false;
}
}
if ($action['properties']['returns_within']) {
if ($latestHit && $now->getTimestamp() - $latestHit->getTimestamp() <= $action['properties']['returns_within']) {
$changePoints['returns_within'] = true;
} else {
$changePoints['returns_within'] = false;
}
}
if ($action['properties']['returns_after']) {
if ($latestHit && $now->getTimestamp() - $latestHit->getTimestamp() >= $action['properties']['returns_after']) {
$changePoints['returns_after'] = true;
} else {
$changePoints['returns_after'] = false;
}
}
// return true only if all configured options are true
return !in_array(false, $changePoints);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\PageBundle\Helper;
use Mautic\PageBundle\Model\PageModel;
class TokenHelper
{
public const REGEX = '/{pagelink=(.*?)}/';
public function __construct(
protected PageModel $model,
) {
}
public function findPageTokens($content, $clickthrough = []): array
{
preg_match_all(self::REGEX, $content, $matches);
$tokens = [];
if (!empty($matches[1])) {
foreach ($matches[1] as $key => $pageId) {
$token = $matches[0][$key];
if (!empty($tokens[$token])) {
continue;
}
$page = $this->model->getEntity($pageId);
if (!$page) {
continue;
}
$tokens[$token] = $this->model->generateUrl($page, true, $clickthrough);
}
unset($matches);
}
return $tokens;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Mautic\PageBundle\Helper;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\Serializer;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\RequestStack;
class TrackingHelper
{
public function __construct(
protected ContactTracker $contactTracker,
protected CacheProvider $cache,
protected CoreParametersHelper $coreParametersHelper,
protected RequestStack $requestStack,
) {
}
/**
* @return array<string, 'facebook_pixel'|'google_analytics'>
*/
public function getEnabledServices(): array
{
$keys = [
'google_analytics' => 'Google Analytics',
'facebook_pixel' => 'Facebook Pixel',
];
$result = [];
foreach ($keys as $key => $service) {
if ($id = $this->coreParametersHelper->get($key.'_id')) {
$result[$service] = $key;
}
}
return $result;
}
/**
* @return string|null
*/
private function getCacheKey()
{
$lead = $this->contactTracker->getContact();
return $lead instanceof Lead ? 'mtc-tracking-pixel-events-'.$lead->getId() : null;
}
/**
* @param mixed[] $values
*
* @throws InvalidArgumentException
*/
public function updateCacheItem(array $values): void
{
$cacheKey = $this->getCacheKey();
if (null !== $cacheKey) {
$item = $this->cache->getItem($cacheKey);
$item->set(serialize(array_merge($values, $this->getCacheItem())));
$item->expiresAfter(86400); // one day in seconds
$this->cache->save($item);
}
}
/**
* @return mixed[]
*
* @throws InvalidArgumentException
*/
public function getCacheItem(bool $remove = false): array
{
$cacheKey = $this->getCacheKey();
$cacheValue = [];
/* @var CacheItemInterface $item */
if (null !== $cacheKey) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$cacheValue = Serializer::decode($item->get(), ['allowed_classes' => false]);
if ($remove) {
$this->cache->deleteItem($cacheKey);
}
}
}
return (array) $cacheValue;
}
/**
* @return bool|mixed
*/
public function displayInitCode($service)
{
$pixelId = $this->coreParametersHelper->get($service.'_id');
if ($pixelId && $this->coreParametersHelper->get($service.'_landingpage_enabled') && $this->isLandingPage()) {
return $pixelId;
}
if ($pixelId && $this->coreParametersHelper->get($service.'_trackingpage_enabled') && !$this->isLandingPage()) {
return $pixelId;
}
return false;
}
/**
* @return Lead|null
*/
public function getLead()
{
return $this->contactTracker->getContact();
}
public function getAnonymizeIp()
{
return $this->coreParametersHelper->get('google_analytics_anonymize_ip');
}
protected function isLandingPage(): bool
{
$server = $this->requestStack->getCurrentRequest()->server;
if (!str_contains((string) $server->get('HTTP_REFERER'), $this->coreParametersHelper->get('site_url'))) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\PageBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticPageBundle extends Bundle
{
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\PageDraft;
use Mautic\PageBundle\Entity\PageDraftRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PageDraftModel
{
public function __construct(
private EntityManagerInterface $entityManager,
private PageDraftRepository $pageDraftRepository,
) {
}
/**
* @throws \Exception
*/
public function createDraft(Page $page, string $html, string $template, bool $publicPreview = true): PageDraft
{
$pageDraft = $this->pageDraftRepository->findOneBy(['page' => $page]);
if (!is_null($pageDraft)) {
throw new \Exception(sprintf('Draft already exists for page %d', $page->getId()));
}
$pageDraft = new PageDraft($page, $html, $template, $publicPreview);
$this->entityManager->persist($pageDraft);
$this->entityManager->flush();
return $pageDraft;
}
public function saveDraft(PageDraft $pageDraft): void
{
$this->entityManager->persist($pageDraft);
$this->entityManager->flush();
}
public function deleteDraft(Page $page): void
{
if (is_null($pageDraft = $page->getDraft())) {
throw new NotFoundHttpException(sprintf('Draft not found for page %d', $page->getId()));
}
$this->entityManager->remove($pageDraft);
$this->entityManager->flush();
}
public function getEntity(int $id): ?PageDraft
{
return $this->pageDraftRepository->find($id);
}
public function getPermissionBase(): string
{
return 'page:pages';
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace Mautic\PageBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UrlHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Shortener\Shortener;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\RedirectRepository;
use Mautic\PageBundle\Event\RedirectGenerationEvent;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends FormModel<Redirect>
*/
class RedirectModel extends FormModel
{
public function __construct(
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
private Shortener $shortener,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getRepository(): RedirectRepository
{
return $this->em->getRepository(Redirect::class);
}
/**
* @return Redirect|null
*/
public function getRedirectById($identifier)
{
return $this->getRepository()->findOneBy(['redirectId' => $identifier]);
}
/**
* Generate a Mautic redirect/passthrough URL.
*
* @param array $clickthrough
* @param bool $shortenUrl
* @param array $utmTags
*
* @return string
*/
public function generateRedirectUrl(Redirect $redirect, $clickthrough = [], $shortenUrl = false, $utmTags = [])
{
if ($this->dispatcher->hasListeners(PageEvents::ON_REDIRECT_GENERATE)) {
$event = new RedirectGenerationEvent($redirect, $clickthrough);
$this->dispatcher->dispatch($event, PageEvents::ON_REDIRECT_GENERATE);
$clickthrough = $event->getClickthrough();
}
$url = $this->buildUrl(
'mautic_url_redirect',
['redirectId' => $redirect->getRedirectId()],
true,
$clickthrough
);
if (!empty($utmTags)) {
$utmTags = $this->getUtmTagsForUrl($utmTags);
$appendUtmString = http_build_query($utmTags, '', '&');
$url = UrlHelper::appendQueryToUrl($url, $appendUtmString);
}
if ($shortenUrl) {
$url = $this->shortener->shortenUrl($url);
}
return $url;
}
/**
* Generate UTMs params for url.
*/
public function getUtmTagsForUrl($rawUtmTags): array
{
$utmTags = [];
foreach ($rawUtmTags as $utmTag => $value) {
$utmTags[str_replace('utm', 'utm_', strtolower($utmTag))] = $value;
}
return $utmTags;
}
/**
* Get a Redirect entity by URL.
*
* Use Mautic\PageBundle\Model\TrackableModel::getTrackableByUrl() if associated with a channel
*
* @return Redirect|null
*/
public function getRedirectByUrl($url)
{
// Ensure the URL saved to the database does not have encoded ampersands
$url = UrlHelper::decodeAmpersands($url);
$repo = $this->getRepository();
$redirect = $repo->findOneBy(['url' => $url]);
if (null == $redirect) {
$redirect = $this->createRedirectEntity($url);
}
return $redirect;
}
/**
* Get Redirect entities by an array of URLs.
*
* @return array<Redirect>
*/
public function getRedirectsByUrls(array $urls)
{
/** @var array<Redirect> $redirects */
$redirects = $this->getRepository()->findByUrls(array_values($urls));
$newEntities = [];
/** @var array<string, Redirect> $return */
$return = [];
/** @var array<string, Redirect> $byUrl */
$byUrl = [];
foreach ($redirects as $redirect) {
$byUrl[$redirect->getUrl()] = $redirect;
}
foreach ($urls as $key => $url) {
if (empty($url)) {
continue;
}
if (isset($byUrl[$url])) {
$return[$key] = $byUrl[$url];
} else {
$redirect = $this->createRedirectEntity($url);
$newEntities[] = $redirect;
$return[$key] = $redirect;
}
}
// Save new entities
if (count($newEntities)) {
$this->getRepository()->saveEntities($newEntities);
}
unset($redirects, $newEntities, $byUrl);
return $return;
}
/**
* Create a Redirect entity for URL.
*
* @return Redirect
*/
public function createRedirectEntity($url)
{
$redirect = new Redirect();
$redirect->setUrl($url);
$redirect->setRedirectId();
$this->setTimestamps($redirect, true);
return $redirect;
}
}

View File

@@ -0,0 +1,892 @@
<?php
namespace Mautic\PageBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UrlHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Helper\TokenHelper;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Event\UntrackableUrlsEvent;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends AbstractCommonModel<Trackable>
*/
class TrackableModel extends AbstractCommonModel
{
/**
* Array of URLs and/or tokens that should not be converted to trackables.
*
* @var array
*/
protected $doNotTrack = [];
/**
* Tokens with values that could be used as URLs.
*
* @var array
*/
protected $contentTokens = [];
/**
* Stores content that needs to be replaced when URLs are parsed out of content.
*
* @var array
*/
protected $contentReplacements = [];
/**
* Used to rebuild correct URLs when the tokenized URL contains query parameters.
*
* @var bool
*/
protected $usingClickthrough = true;
private ?array $contactFieldUrlTokens = null;
public function __construct(
protected RedirectModel $redirectModel,
private LeadFieldRepository $leadFieldRepository,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return \Mautic\PageBundle\Entity\TrackableRepository
*/
public function getRepository()
{
return $this->em->getRepository(Trackable::class);
}
/**
* @return RedirectModel
*/
protected function getRedirectModel()
{
return $this->redirectModel;
}
/**
* @param array $clickthrough
* @param bool|false $shortenUrl If true, use the configured shortener service to shorten the URLs
* @param array $utmTags
*
* @return string
*/
public function generateTrackableUrl(Trackable $trackable, $clickthrough = [], $shortenUrl = false, $utmTags = [])
{
if (!isset($clickthrough['channel'])) {
$clickthrough['channel'] = [$trackable->getChannel() => $trackable->getChannelId()];
}
$redirect = $trackable->getRedirect();
return $this->getRedirectModel()->generateRedirectUrl($redirect, $clickthrough, $shortenUrl, $utmTags);
}
/**
* Return a channel Trackable entity by URL.
*
* @return Trackable|null
*/
public function getTrackableByUrl($url, $channel, $channelId)
{
if (empty($url)) {
return null;
}
// Ensure the URL saved to the database does not have encoded ampersands
$url = UrlHelper::decodeAmpersands($url);
$trackable = $this->getRepository()->findByUrl($url, $channel, $channelId);
if (null == $trackable) {
$trackable = $this->createTrackableEntity($url, $channel, $channelId);
$this->getRepository()->saveEntity($trackable->getRedirect());
$this->getRepository()->saveEntity($trackable);
}
return $trackable;
}
/**
* Get Trackable entities by an array of URLs.
*
* @return array<Trackable>
*/
public function getTrackablesByUrls($urls, $channel, $channelId)
{
$uniqueUrls = array_unique(
array_values($urls)
);
$trackables = $this->getRepository()->findByUrls(
$uniqueUrls,
$channel,
$channelId
);
$newRedirects = [];
$newTrackables = [];
/** @var array<Trackable> $return */
$return = [];
/** @var array<string, Trackable> $byUrl */
$byUrl = [];
/** @var Trackable $trackable */
foreach ($trackables as $trackable) {
$url = $trackable->getRedirect()->getUrl();
$byUrl[$url] = $trackable;
}
foreach ($urls as $key => $url) {
if (empty($url)) {
continue;
}
if (isset($byUrl[$url])) {
$return[$key] = $byUrl[$url];
} else {
$trackable = $this->createTrackableEntity($url, $channel, $channelId);
// Redirect has to be saved first to have ID available
$newRedirects[] = $trackable->getRedirect();
$newTrackables[] = $trackable;
$return[$key] = $trackable;
// Keep track so it can be re-used if applicable
$byUrl[$url] = $trackable;
}
}
// Save new entities
if (count($newRedirects)) {
$this->getRepository()->saveEntities($newRedirects);
}
if (count($newTrackables)) {
$this->getRepository()->saveEntities($newTrackables);
}
unset($trackables, $newRedirects, $newTrackables, $byUrl);
return $return;
}
/**
* Get a list of URLs that are tracked by a specific channel.
*
* @return mixed[]
*/
public function getTrackableList($channel, $channelId): array
{
return $this->getRepository()->findByChannel($channel, $channelId);
}
/**
* Returns a list of tokens and/or URLs that should not be converted to trackables.
*
* @param string|string[]|null $content
*/
public function getDoNotTrackList($content): array
{
/** @var UntrackableUrlsEvent $event */
$event = $this->dispatcher->dispatch(
new UntrackableUrlsEvent($content),
PageEvents::REDIRECT_DO_NOT_TRACK
);
return $event->getDoNotTrackList();
}
/**
* Extract URLs from content and return as trackables.
*
* @param string|string[] $content
* @param string[] $contentTokens
* @param ?string $channel
* @param ?int $channelId
* @param bool $usingClickthrough Set to false if not using a clickthrough parameter.
* This is to ensure that URLs are built correctly with ? or & for
* URLs tracked that include query parameters
*
* @return array{string|string[],Redirect[]|Trackable[]}
*/
public function parseContentForTrackables($content, array $contentTokens = [], $channel = null, $channelId = null, $usingClickthrough = true): array
{
$this->usingClickthrough = $usingClickthrough;
// Set do not track list for validateUrlIsTrackable()
$this->doNotTrack = $this->getDoNotTrackList($content);
// Set content tokens used by validateUrlIsTrackable()
$this->contentTokens = $contentTokens;
$contentWasString = false;
if (!is_array($content)) {
$contentWasString = true;
$content = [$content];
}
$trackableTokens = [];
foreach ($content as $key => $text) {
$content[$key] = $this->parseContent($text, $channel, $channelId, $trackableTokens);
}
return [
$contentWasString ? $content[0] : $content,
$trackableTokens,
];
}
/**
* Converts array of Trackable or Redirect entities into {trackable} tokens.
*
* @param array<string, Trackable|Redirect> $entities
*
* @return array<string, Redirect|Trackable>
*/
protected function createTrackingTokens(array $entities): array
{
$tokens = [];
foreach ($entities as $trackable) {
$redirect = ($trackable instanceof Trackable) ? $trackable->getRedirect() : $trackable;
$token = '{trackable='.$redirect->getRedirectId().'}';
$tokens[$token] = $trackable;
// Store the URL to be replaced by a token
$this->contentReplacements['second_pass'][$redirect->getUrl()] = $token;
}
return $tokens;
}
/**
* Prepares content for tokenized trackable URLs by replacing them with {trackable=ID} tokens.
*
* @param string $content
* @param string $type html|text
*
* @return string
*/
protected function prepareContentWithTrackableTokens($content, $type)
{
if (empty($content)) {
return '';
}
// Simple search and replace to remove attributes, schema for tokens, and updating URL parameter order
$firstPassSearch = array_keys($this->contentReplacements['first_pass']);
$firstPassReplace = $this->contentReplacements['first_pass'];
$content = str_ireplace($firstPassSearch, $firstPassReplace, $content);
// Sort longer to shorter strings to ensure that URLs that share the same base are appropriately replaced
uksort($this->contentReplacements['second_pass'], fn ($a, $b): int => strlen($b) - strlen($a));
if ('html' == $type) {
// For HTML, replace only the links; leaving the link text (if a URL) intact
foreach ($this->contentReplacements['second_pass'] as $search => $replace) {
// Make the search regular expression match both "&" and "&amp;".
$search = preg_quote($search, '/');
$search = str_replace('&amp;', '&', $search);
$search = str_replace('&', '(?:&|&amp;)', $search);
$content = preg_replace(
'/<(.*?) href=(["\'])'.$search.'(.*?)\\2(.*?)>/i',
'<$1 href=$2'.$replace.'$3$2$4>',
$content
);
}
} else {
// For text, just do a simple search/replace
$secondPassSearch = array_keys($this->contentReplacements['second_pass']);
$secondPassReplace = $this->contentReplacements['second_pass'];
$content = str_ireplace($secondPassSearch, $secondPassReplace, $content);
}
return $content;
}
/**
* @return array
*/
protected function extractTrackablesFromContent($content)
{
if (0 !== preg_match('/<[^<]+>/', $content)) {
// Parse as HTML
$trackableUrls = $this->extractTrackablesFromHtml($content);
} else {
// Parse as plain text
$trackableUrls = $this->extractTrackablesFromText($content);
}
return $trackableUrls;
}
/**
* Find URLs in HTML and parse into trackables.
*
* @param string $html HTML content
*/
protected function extractTrackablesFromHtml($html): array
{
// Find links using DOM to only find <a> tags
$libxmlPreviousState = libxml_use_internal_errors(true);
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
$dom->loadHTML('<?xml encoding="UTF-8">'.$html);
libxml_clear_errors();
libxml_use_internal_errors($libxmlPreviousState);
$links = $dom->getElementsByTagName('a');
$xpath = new \DOMXPath($dom);
$maps = $xpath->query('//map/area');
return array_merge($this->extractTrackables($links), $this->extractTrackables($maps));
}
/**
* Find URLs in plain text and parse into trackables.
*
* @param string $text Plain text content
*/
protected function extractTrackablesFromText($text): array
{
// Remove any HTML tags (such as img) that could contain href or src attributes prior to parsing for links
$text = strip_tags($text);
// Get a list of URL type contact fields
$allUrls = UrlHelper::getUrlsFromPlaintext($text, $this->getContactFieldUrlTokens());
$trackableUrls = [];
foreach ($allUrls as $url) {
if ($preparedUrl = $this->prepareUrlForTracking($url)) {
[$urlKey, $urlValue] = $preparedUrl;
$trackableUrls[$urlKey] = $urlValue;
}
}
return $trackableUrls;
}
/**
* Create a Trackable entity.
*/
protected function createTrackableEntity($url, $channel, $channelId): Trackable
{
$redirect = $this->getRedirectModel()->createRedirectEntity($url);
$trackable = new Trackable();
$trackable->setChannel($channel)
->setChannelId($channelId)
->setRedirect($redirect);
return $trackable;
}
/**
* Validate and parse link for tracking.
*
* @return bool|non-empty-array<mixed, mixed>
*/
protected function prepareUrlForTracking(string $url)
{
// Ensure it's clean
$url = trim($url);
// Ensure ampersands are & for the sake of parsing
$url = UrlHelper::decodeAmpersands($url);
// If this is just a token, validate it's supported before going further
if (preg_match('/^{.*?}$/i', $url) && !$this->validateTokenIsTrackable($url)) {
return false;
}
// Default key and final URL to the given $url
$trackableKey = $trackableUrl = $url;
// Convert URL
$urlParts = parse_url($url);
// We need to ignore not parsable and invalid urls
if (false === $urlParts || !$this->isValidUrl($urlParts, false)) {
return false;
}
// Check if URL is trackable
$tokenizedHost = (!isset($urlParts['host']) && isset($urlParts['path'])) ? $urlParts['path'] : $urlParts['host'];
if (preg_match('/^(\{\S+?\})/', $tokenizedHost, $match)) {
$token = $match[1];
// Tokenized hosts that are standalone tokens shouldn't use a scheme since the token value should contain it
if ($token === $tokenizedHost && $scheme = (!empty($urlParts['scheme'])) ? $urlParts['scheme'] : false) {
// Token has a schema so let's get rid of it before replacing tokens
$this->contentReplacements['first_pass'][$scheme.'://'.$tokenizedHost] = $tokenizedHost;
unset($urlParts['scheme']);
}
// Validate that the token is something that can be trackable
if (!$this->validateTokenIsTrackable($token, $tokenizedHost)) {
return false;
}
// Do not convert contact tokens
if (!$this->isContactFieldToken($token)) {
$trackableUrl = (!empty($urlParts['query'])) ? $this->contentTokens[$token].'?'.$urlParts['query'] : $this->contentTokens[$token];
$trackableKey = $trackableUrl;
// Replace the URL token with the actual URL
$this->contentReplacements['first_pass'][$url] = $trackableUrl;
}
} else {
// Regular URL without a tokenized host
$trackableUrl = $this->httpBuildUrl($urlParts);
if ($this->isInDoNotTrack($trackableUrl)) {
return false;
}
}
if ($this->isInDoNotTrack($trackableUrl)) {
return false;
}
return [$trackableKey, $trackableUrl];
}
/**
* Determines if a URL/token is in the do not track list.
*/
protected function isInDoNotTrack($url): bool
{
// Ensure it's not in the do not track list
foreach ($this->doNotTrack as $notTrackable) {
if (preg_match('~'.$notTrackable.'~', $url)) {
return true;
}
}
return false;
}
/**
* Validates that a token is trackable as a URL.
*/
protected function validateTokenIsTrackable($token, $tokenizedHost = null): bool
{
// Validate if this token is listed as not to be tracked
if ($this->isInDoNotTrack($token)) {
return false;
}
if ($this->isContactFieldToken($token)) {
// Assume it's true as the redirect methods should handle this dynamically
return true;
}
$tokenValue = TokenHelper::getValueFromTokens($this->contentTokens, $token);
// Validate that the token is available
if (!$tokenValue) {
return false;
}
if ($tokenizedHost) {
$url = str_ireplace($token, $tokenValue, $tokenizedHost);
return $this->isValidUrl($url, false);
}
if (!$this->isValidUrl($tokenValue)) {
return false;
}
return true;
}
/**
* @param bool $forceScheme
*/
protected function isValidUrl($url, $forceScheme = true): bool
{
$urlParts = (!is_array($url)) ? parse_url($url) : $url;
// Ensure a applicable URL (rule out URLs as just #)
if (!isset($urlParts['host']) && !isset($urlParts['path'])) {
return false;
}
// Ensure a valid scheme
if (($forceScheme && !isset($urlParts['scheme']))
|| (isset($urlParts['scheme'])
&& !in_array(
$urlParts['scheme'],
['http', 'https', 'ftp', 'ftps', 'mailto']
))) {
return false;
}
return true;
}
/**
* Find and extract tokens from the URL as this have to be processed outside of tracking tokens.
*
* @param $urlParts Array from parse_url
*
* @return array|false
*/
protected function extractTokensFromQuery(&$urlParts)
{
$tokenizedParams = false;
// Check for a token with a query appended such as {pagelink=1}&key=value
if (isset($urlParts['path']) && preg_match('/([https?|ftps?]?\{.*?\})&(.*?)$/', $urlParts['path'], $match)) {
$urlParts['path'] = $match[1];
if (isset($urlParts['query'])) {
// Likely won't happen but append if this exists
$urlParts['query'] .= '&'.$match[2];
} else {
$urlParts['query'] = $match[2];
}
}
// Check for tokens in the query
if (!empty($urlParts['query'])) {
[$tokenizedParams, $untokenizedParams] = $this->parseTokenizedQuery($urlParts['query']);
if ($tokenizedParams) {
// Rebuild the query without the tokenized query params for now
$urlParts['query'] = $this->httpBuildQuery($untokenizedParams);
}
}
return $tokenizedParams;
}
/**
* Group query parameters into those that have tokens and those that do not.
*
* @return array<array<string, mixed>> [$tokenizedParams[], $untokenizedParams[]]
*/
protected function parseTokenizedQuery($query): array
{
$tokenizedParams =
$untokenizedParams = [];
// Test to see if there are tokens in the query and if so, extract and append them to the end of the tracked link
if (preg_match('/(\{\S+?\})/', $query)) {
// Equal signs in tokens will confuse parse_str so they need to be encoded
$query = preg_replace('/\{(\S+?)=(\S+?)\}/', '{$1%3D$2}', $query);
parse_str($query, $queryParts);
foreach ($queryParts as $key => $value) {
if (preg_match('/(\{\S+?\})/', $key) || preg_match('/(\{\S+?\})/', $value)) {
$tokenizedParams[$key] = $value;
} else {
$untokenizedParams[$key] = $value;
}
}
}
return [$tokenizedParams, $untokenizedParams];
}
/**
* @return array<string, Trackable|Redirect>
*/
protected function getEntitiesFromUrls($trackableUrls, $channel, $channelId)
{
if (!empty($channel) && !empty($channelId)) {
// Track as channel aware
return $this->getTrackablesByUrls($trackableUrls, $channel, $channelId);
}
// Simple redirects
return $this->getRedirectModel()->getRedirectsByUrls($trackableUrls);
}
/**
* @return string
*/
protected function httpBuildUrl($parts)
{
if (function_exists('http_build_url')) {
return http_build_url($parts);
} else {
/*
* Used if extension is not installed
*
* http_build_url
* Stand alone version of http_build_url (http://php.net/manual/en/function.http-build-url.php)
* Based on buggy and inefficient version I found at http://www.mediafire.com/?zjry3tynkg5 by tycoonmaster[at]gmail[dot]com
*
* @author Chris Nasr (chris[at]fuelforthefire[dot]ca)
* @copyright Fuel for the Fire
* @package http
* @version 0.1
* @created 2012-07-26
*/
if (!defined('HTTP_URL_REPLACE')) {
// Define constants
define('HTTP_URL_REPLACE', 0x0001); // Replace every part of the first URL when there's one of the second URL
define('HTTP_URL_JOIN_PATH', 0x0002); // Join relative paths
define('HTTP_URL_JOIN_QUERY', 0x0004); // Join query strings
define('HTTP_URL_STRIP_USER', 0x0008); // Strip any user authentication information
define('HTTP_URL_STRIP_PASS', 0x0010); // Strip any password authentication information
define('HTTP_URL_STRIP_PORT', 0x0020); // Strip explicit port numbers
define('HTTP_URL_STRIP_PATH', 0x0040); // Strip complete path
define('HTTP_URL_STRIP_QUERY', 0x0080); // Strip query string
define('HTTP_URL_STRIP_FRAGMENT', 0x0100); // Strip any fragments (#identifier)
// Combination constants
define('HTTP_URL_STRIP_AUTH', HTTP_URL_STRIP_USER | HTTP_URL_STRIP_PASS);
define('HTTP_URL_STRIP_ALL', HTTP_URL_STRIP_AUTH | HTTP_URL_STRIP_PORT | HTTP_URL_STRIP_QUERY | HTTP_URL_STRIP_FRAGMENT);
}
$flags = HTTP_URL_REPLACE;
$url = [];
// Scheme and Host are always replaced
if (isset($parts['scheme'])) {
$url['scheme'] = $parts['scheme'];
}
if (isset($parts['host'])) {
$url['host'] = $parts['host'];
}
// (If applicable) Replace the original URL with it's new parts
if (HTTP_URL_REPLACE & $flags) {
// Go through each possible key
foreach (['user', 'pass', 'port', 'path', 'query', 'fragment'] as $key) {
// If it's set in $parts, replace it in $url
if (isset($parts[$key])) {
$url[$key] = $parts[$key];
}
}
} else {
// Join the original URL path with the new path
if (isset($parts['path']) && (HTTP_URL_JOIN_PATH & $flags)) {
if (isset($url['path']) && '' != $url['path']) {
// If the URL doesn't start with a slash, we need to merge
if ('/' != $url['path'][0]) {
// If the path ends with a slash, store as is
if ('/' == $parts['path'][strlen($parts['path']) - 1]) {
$sBasePath = $parts['path'];
} // Else trim off the file
else {
// Get just the base directory
$sBasePath = dirname($parts['path']);
}
// If it's empty
if ('' == $sBasePath) {
$sBasePath = '/';
}
// Add the two together
$url['path'] = $sBasePath.$url['path'];
// Free memory
unset($sBasePath);
}
if (str_contains($url['path'], './')) {
// Remove any '../' and their directories
while (preg_match('/\w+\/\.\.\//', $url['path'])) {
$url['path'] = preg_replace('/\w+\/\.\.\//', '', $url['path']);
}
// Remove any './'
$url['path'] = str_replace('./', '', $url['path']);
}
} else {
$url['path'] = $parts['path'];
}
}
// Join the original query string with the new query string
if (isset($parts['query']) && (HTTP_URL_JOIN_QUERY & $flags)) {
if (isset($url['query'])) {
$url['query'] .= '&'.$parts['query'];
} else {
$url['query'] = $parts['query'];
}
}
}
// Strips all the applicable sections of the URL
if (HTTP_URL_STRIP_USER & $flags) {
unset($url['user']);
}
if (HTTP_URL_STRIP_PASS & $flags) {
unset($url['pass']);
}
if (HTTP_URL_STRIP_PORT & $flags) {
unset($url['port']);
}
if (HTTP_URL_STRIP_PATH & $flags) {
unset($url['path']);
}
if (HTTP_URL_STRIP_QUERY & $flags) {
unset($url['query']);
}
if (HTTP_URL_STRIP_FRAGMENT & $flags) {
unset($url['fragment']);
}
// Combine the new elements into a string and return it
return
((isset($url['scheme'])) ? 'mailto' == $url['scheme'] ? $url['scheme'].':' : $url['scheme'].'://' : '')
.((isset($url['user'])) ? $url['user'].((isset($url['pass'])) ? ':'.$url['pass'] : '').'@' : '')
.($url['host'] ?? '')
.((isset($url['port'])) ? ':'.$url['port'] : '')
.($url['path'] ?? '')
.((!empty($url['query'])) ? '?'.$url['query'] : '')
.((!empty($url['fragment'])) ? '#'.$url['fragment'] : '');
}
}
/**
* Build query string while accounting for tokens that include an equal sign.
*
* @return mixed|string
*/
protected function httpBuildQuery(array $queryParts)
{
$query = http_build_query($queryParts);
// http_build_query likely encoded tokens so that has to be fixed so they get replaced
$query = preg_replace_callback(
'/%7B(\S+?)%7D/i',
fn ($matches): string => urldecode($matches[0]),
$query
);
return $query;
}
private function isContactFieldToken($token): bool
{
return str_contains($token, '{contactfield') || str_contains($token, '{leadfield');
}
/**
* @param array<int|string, Redirect|Trackable> $trackableTokens
*
* @return string
*/
private function parseContent($content, $channel, $channelId, array &$trackableTokens)
{
// Reset content replacement arrays
$this->contentReplacements = [
// PHPSTAN reported duplicate keys in this array. I can't determine which is the right one.
// I'm leaving the second one to keep current behaviour but leaving the first one commented
// out as it may be the one we want.
// 'first_pass' => [
// // Remove internal attributes
// // Editor may convert to HTML4
// 'mautic:disable-tracking=""' => '',
// // HTML5
// 'mautic:disable-tracking' => '',
// ],
'first_pass' => [],
'second_pass' => [],
];
$trackableUrls = $this->extractTrackablesFromContent($content);
$contentType = (preg_match('/<(.*?) href/i', $content)) ? 'html' : 'text';
if (count($trackableUrls)) {
// Create Trackable/Redirect entities for the URLs
$entities = $this->getEntitiesFromUrls($trackableUrls, $channel, $channelId);
unset($trackableUrls);
// Get a list of url => token to return to calling method and also to be used to
// replace the urls in the content with tokens
$trackableTokens = array_merge(
$trackableTokens,
$this->createTrackingTokens($entities)
);
unset($entities);
// Replace URLs in content with tokens
$content = $this->prepareContentWithTrackableTokens($content, $contentType);
} elseif (!empty($this->contentReplacements['first_pass'])) {
// Replace URLs in content with tokens
$content = $this->prepareContentWithTrackableTokens($content, $contentType);
}
return $content;
}
/**
* @return array
*/
protected function getContactFieldUrlTokens()
{
if (null !== $this->contactFieldUrlTokens) {
return $this->contactFieldUrlTokens;
}
$this->contactFieldUrlTokens = [];
$fieldEntities = $this->leadFieldRepository->getFieldsByType('url');
foreach ($fieldEntities as $field) {
$this->contactFieldUrlTokens[] = $field->getAlias();
}
$this->leadFieldRepository->detachEntities($fieldEntities);
return $this->contactFieldUrlTokens;
}
/**
* @param \DOMNodeList<\DOMNode> $links
*
* @return array<string, string>
*/
private function extractTrackables(\DOMNodeList $links): array
{
$trackableUrls = [];
/** @var \DOMElement $link */
foreach ($links as $link) {
$url = $link->getAttribute('href');
if ('' === $url) {
continue;
}
// Check for a do not track
if ($link->hasAttribute('mautic:disable-tracking')) {
$this->doNotTrack[$url] = $url;
continue;
}
if ($preparedUrl = $this->prepareUrlForTracking($url)) {
[$urlKey, $urlValue] = $preparedUrl;
$trackableUrls[$urlKey] = $urlValue;
}
}
return $trackableUrls;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\PageBundle\Model;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use Symfony\Component\HttpFoundation\Request;
class Tracking404Model
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private ContactTracker $contactTracker,
private PageModel $pageModel,
) {
}
/**
* @param Page|Redirect $entity
*
* @throws \Exception
*/
public function hitPage($entity, Request $request): void
{
$this->pageModel->hitPage($entity, $request, 404);
}
public function isTrackable(): bool
{
if (!$this->coreParametersHelper->get('do_not_track_404_anonymous')) {
return true;
}
// already tracked and identified contact
if ($lead = $this->contactTracker->getContactByTrackedDevice()) {
if (!$lead->isAnonymous()) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Mautic\PageBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Entity\VideoHit;
use Mautic\PageBundle\Entity\VideoHitRepository;
use Mautic\PageBundle\Event\VideoHitEvent;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends FormModel<VideoHit>
*/
class VideoModel extends FormModel
{
public function __construct(
protected IpLookupHelper $ipLookupHelper,
protected ContactTracker $contactTracker,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getHitRepository(): VideoHitRepository
{
return $this->em->getRepository(VideoHit::class);
}
public function getPermissionBase(): string
{
return 'page:pages';
}
public function getNameGetter(): string
{
return 'getTitle';
}
/**
* @param string $guid
*
* @return VideoHit
*/
public function getHitForLeadByGuid(Lead $lead, $guid)
{
return $this->getHitRepository()->getHitForLeadByGuid($lead, $guid);
}
/**
* @param Request $request
* @param string $code
*
* @throws \Doctrine\ORM\ORMException
* @throws \Exception
*/
public function hitVideo($request, $code = '200'): void
{
// don't skew results with in-house hits
if (!$this->security->isAnonymous()) {
// return;
}
$lead = $this->contactTracker->getContact();
$guid = $request->get('guid');
$hit = ($lead) ? $this->getHitForLeadByGuid($lead, $guid) : new VideoHit();
$hit->setGuid($guid);
$hit->setDateHit(new \DateTime());
$hit->setDuration($request->get('duration'));
$hit->setUrl($request->get('url'));
$hit->setTimeWatched($request->get('total_watched'));
// check for existing IP
$ipAddress = $this->ipLookupHelper->getIpAddress();
$hit->setIpAddress($ipAddress);
// Store query array
$query = $request->query->all();
unset($query['d']);
$hit->setQuery($query);
if ($lead) {
$hit->setLead($lead);
}
// glean info from the IP address
if ($details = $ipAddress->getIpDetails()) {
$hit->setCountry($details['country']);
$hit->setRegion($details['region']);
$hit->setCity($details['city']);
$hit->setIsp($details['isp']);
$hit->setOrganization($details['organization']);
}
$hit->setCode($code);
if (!$hit->getReferer()) {
$hit->setReferer($request->server->get('HTTP_REFERER'));
}
$hit->setUserAgent($request->server->get('HTTP_USER_AGENT'));
$hit->setRemoteHost($request->server->get('REMOTE_HOST'));
// get a list of the languages the user prefers
$browserLanguages = $request->server->get('HTTP_ACCEPT_LANGUAGE');
if (!empty($browserLanguages)) {
$languages = explode(',', $browserLanguages);
foreach ($languages as $k => $l) {
if (($pos = strpos(';q=', $l)) !== false) {
// remove weights
$languages[$k] = substr($l, 0, $pos);
}
}
$hit->setBrowserLanguages($languages);
}
// Wrap in a try/catch to prevent deadlock errors on busy servers
try {
$this->em->persist($hit);
$this->em->flush();
} catch (\Exception $exception) {
if (MAUTIC_ENV === 'dev') {
throw $exception;
} else {
$this->logger->error(
$exception->getMessage(),
['exception' => $exception]
);
}
}
if ($this->dispatcher->hasListeners(PageEvents::VIDEO_ON_HIT)) {
$event = new VideoHitEvent($hit, $request, $code);
$this->dispatcher->dispatch($event, PageEvents::VIDEO_ON_HIT);
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace Mautic\PageBundle;
/**
* Events available for PageBundle.
*/
final class PageEvents
{
/**
* The mautic.video_on_hit event is thrown when a public page is browsed and a hit recorded in the analytics table.
*
* The event listener receives a Mautic\PageBundle\Event\VideoHitEvent instance.
*
* @var string
*/
public const VIDEO_ON_HIT = 'mautic.video_on_hit';
/**
* The mautic.page_on_hit event is thrown when a public page is browsed and a hit recorded in the analytics table.
*
* The event listener receives a Mautic\PageBundle\Event\PageHitEvent instance.
*
* @var string
*/
public const PAGE_ON_HIT = 'mautic.page_on_hit';
/**
* The mautic.page_on_build event is thrown before displaying the page builder form to allow adding of tokens.
*
* The event listener receives a Mautic\PageBundle\Event\PageEvent instance.
*
* @var string
*/
public const PAGE_ON_BUILD = 'mautic.page_on_build';
/**
* The mautic.page_on_display event is thrown before displaying the page content.
*
* The event listener receives a Mautic\PageBundle\Event\PageDisplayEvent instance.
*
* @var string
*/
public const PAGE_ON_DISPLAY = 'mautic.page_on_display';
/**
* The mautic.page_pre_save event is thrown right before a page is persisted.
*
* The event listener receives a Mautic\PageBundle\Event\PageEvent instance.
*
* @var string
*/
public const PAGE_PRE_SAVE = 'mautic.page_pre_save';
/**
* The mautic.page_post_save event is thrown right after a page is persisted.
*
* The event listener receives a Mautic\PageBundle\Event\PageEvent instance.
*
* @var string
*/
public const PAGE_POST_SAVE = 'mautic.page_post_save';
/**
* The mautic.page_pre_delete event is thrown prior to when a page is deleted.
*
* The event listener receives a Mautic\PageBundle\Event\PageEvent instance.
*
* @var string
*/
public const PAGE_PRE_DELETE = 'mautic.page_pre_delete';
/**
* The mautic.page_post_delete event is thrown after a page is deleted.
*
* The event listener receives a Mautic\PageBundle\Event\PageEvent instance.
*
* @var string
*/
public const PAGE_POST_DELETE = 'mautic.page_post_delete';
/**
* The mautic.redirect_do_not_track event is thrown when converting email links to trackables/redirectables in order to compile of list of tokens/URLs
* to ignore.
*
* The event listener receives a Mautic\PageBundle\Event\UntrackableUrlsEvent instance.
*
* @var string
*/
public const REDIRECT_DO_NOT_TRACK = 'mautic.redirect_do_not_track';
/**
* The mautic.page.on_campaign_trigger_decision event is fired when the campaign decision triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_DECISION = 'mautic.page.on_campaign_trigger_decision';
/**
* The mautic.page.on_campaign_trigger_action event is fired when the campaign action fired.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_ACTION = 'mautic.page.on_campaign_trigger_action';
/**
* The mautic.page.on_redirect_generate event is fired when generating a redirect.
*
* The event listener receives a
* Mautic\PageBundle\Event\RedirectGenerationEvent
*/
public const ON_REDIRECT_GENERATE = 'mautic.page.on_redirect_generate';
/**
* The mautic.page.on_bounce_rate_winner event is fired when there is a need to determine bounce rate winner.
*
* The event listener receives a
* Mautic\CoreBundle\Event\DetermineWinnerEvent
*
* @var string
*/
public const ON_DETERMINE_BOUNCE_RATE_WINNER = 'mautic.page.on_bounce_rate_winner';
/**
* The mautic.page.on_dwell_time_winner event is fired when there is a need to determine a winner based on dwell time.
*
* The event listener receives a
* Mautic\CoreBundles\Event\DetermineWinnerEvent
*
* @var string
*/
public const ON_DETERMINE_DWELL_TIME_WINNER = 'mautic.page.on_dwell_time_winner';
/**
* The mautic.page.on_contact_tracked event is dispatched when a contact is tracked via the mt() tracking event.
*
* The event listener receives a
* Mautic\PageBundle\Event\TrackingEvent
*/
public const ON_CONTACT_TRACKED = 'mautic.page.on_contact_tracked';
}

View File

@@ -0,0 +1,3 @@
{% for f in form %}
{{ form_row(f) }}
{% endfor %}

View File

@@ -0,0 +1,17 @@
{% block _config_pageconfig_widget %}
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.pageconfig'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.pageconfig.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
{% for name, f in form.children %}
<div class="col-xs-12">
{{ form_row(f) }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% block _config_trackingconfig_widget %}
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.pagetracking'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.pagetracking.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="form-group">
<p>{{ 'mautic.config.tab.pagetracking.info'|trans|purify }}</p>
{% include '@MauticCore/Components/code-snippet.html.twig' with {
variant: 'multi',
innerText: '&lt;script&gt;
(function(w,d,t,u,n,a,m){w[\'MauticTrackingObject\']=n;
w[n]=w[n]||function(){(w[n].q=w[n].q||[]).push(arguments)},a=d.createElement(t),
m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m)
})(window,document,\'script\',\'' ~ url('mautic_js') ~ '\',\'mt\');
mt(\'send\', \'pageview\');
&lt;/script&gt;',
} %}
</div>
<div class="row">
<hr>
{% for name, f in form.children %}
{% if name in ['anonymize_ip', 'track_contact_by_ip', 'do_not_track_404_anonymous'] %}
<div class="col-xs-12">
{{ form_row(f) }}
{% if name == 'anonymize_ip' %}
<div class="anonymize_ip_address hide text-danger">{{ 'mautic.page.config.form.anonymize_ip.warning'|trans }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.tracking.facebook.pixel'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.tracking.facebook.pixel.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
<div class="col-xs-12">
{{ form_row(form.facebook_pixel_id) }}
</div>
<hr>
{% for name, f in form.children %}
{% if name in ['facebook_pixel_trackingpage_enabled', 'facebook_pixel_landingpage_enabled'] %}
<div class="col-xs-12">
{{ form_row(f) }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.tracking.google.analytics'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.tracking.google.analytics.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
<div class="col-xs-12">
{{ form_row(form.google_analytics_id) }}
</div>
<hr>
{% for name, f in form.children %}
{% if name in ['google_analytics_trackingpage_enabled', 'google_analytics_landingpage_enabled', 'google_analytics_anonymize_ip'] %}
<div class="col-xs-12">
{{ form_row(f) }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
{% block _page_abtest_settings_properties_row %}
{{ form_widget(form) }}
{% endblock %}

View File

@@ -0,0 +1,3 @@
{% block _page_variantSettings_properties_row %}
{{ form_widget(form) }}
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% block pointaction_urlhit_widget %}
{%- set timeFrames = {
's': 'mautic.core.time.seconds'|trans,
'i': 'mautic.core.time.minutes'|trans,
'H': 'mautic.core.time.hours'|trans,
'd': 'mautic.core.time.days'|trans,
} %}
<div class="row">
<div class="col-xs-12">
{{ form_row(form['page_url']) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(form['page_hits']) }}
</div>
</div>
<div class="row">
<div class="col-xs-12 form-group ">
{{ form_label(form['returns_within']) }}
<div class="input-group">
{{ form_widget(form['returns_within']) }}
{%- set default = form.returns_within_unit.vars.data %}
<div class="input-group-btn">
<button type="button" class="btn btn-ghost dropdown-toggle" data-toggle="dropdown">
<span class="returns_within_label">{{ timeFrames[default] }}</span> <span class="caret"></span>
</button>
<ul class="dropdown-menu time-dropdown">
{%- for abbr, label in timeFrames %}
<li><a href="#" data-time="{{ abbr }}" data-field="returns_within">{{ label }}</a></li>
{%- endfor %}
</ul>
</div>
</div>
{{ form_errors(form['returns_within']) }}
{{ form_widget(form['returns_within_unit']) }}
</div>
<div class="col-xs-12 form-group ">
{{ form_label(form['returns_after']) }}
<div class="input-group">
{{ form_widget(form['returns_after']) }}
{%- set default = form['returns_after_unit'].vars['data'] %}
<div class="input-group-btn">
<button type="button" class="btn btn-ghost dropdown-toggle" data-toggle="dropdown">
<span class="returns_after_label">{{ timeFrames[default] }}</span> <span class="caret"></span>
</button>
<ul class="dropdown-menu time-dropdown">
{%- for abbr, label in timeFrames %}
<li><a href="#" data-time="{{ abbr }}" data-field="returns_after">{{ label }}</a></li>
{%- endfor %}
</ul>
</div>
</div>
{{ form_errors(form['returns_after']) }}
{{ form_widget(form['returns_after_unit']) }}
</div>
<div class="col-xs-12 form-group ">
{{ form_label(form['accumulative_time']) }}
<div class="input-group">
{{ form_widget(form['accumulative_time']) }}
{%- set default = form['accumulative_time_unit'].vars['data'] %}
<div class="input-group-btn">
<button type="button" class="btn btn-ghost dropdown-toggle" data-toggle="dropdown">
<span class="accumulative_time_label">{{ timeFrames[default] }}</span> <span class="caret"></span>
</button>
<ul class="dropdown-menu time-dropdown">
{%- for abbr, label in timeFrames %}
<li><a href="#" data-time="{{ abbr }}" data-field="accumulative_time">{{ label }}</a></li>
{%- endfor %}
</ul>
</div>
</div>
{{ form_errors(form['accumulative_time']) }}
{{ form_widget(form['accumulative_time_unit']) }}
</div>
</div>
<script>
mQuery('.time-dropdown li a').click(function (e) {
e.preventDefault();
var selected = mQuery(this).data('time');
var label = mQuery(this).html();
var field = mQuery(this).data('field');
mQuery('#point_properties_' + field + '_unit').val(selected);
mQuery('.' + field + '_label').html(label);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{#
Variables
- searchValue
- items
- categories
- page
- limit
- permissions
- model
- tmpl
- security
#}
{% if items|length > 0 %}
<div class="table-responsive page-list">
<table class="table table-hover pagetable-list" id="pageTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#pageTable',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.title',
'text': 'mautic.core.title',
'class': 'col-page-title',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'c.title',
'text': 'mautic.core.category',
'class': 'visible-md visible-lg col-page-category',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.template',
'text': 'mautic.core.form.theme',
'class': 'visible-md visible-lg col-page-template',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.hits',
'text': 'mautic.page.thead.hits',
'class': 'col-page-hits visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.dateAdded',
'text': 'mautic.lead.import.label.dateAdded',
'class': 'col-page-dateAdded visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.dateModified',
'text': 'mautic.lead.import.label.dateModified',
'class': 'col-page-dateModified visible-md visible-lg',
'default': true,
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.createdByUser',
'text': 'mautic.core.createdby',
'class': 'col-page-createdByUser visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'submission_count',
'text': 'mautic.form.form.results',
'class': 'visible-md visible-lg col-page-submissions',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'page',
'orderBy': 'p.id',
'text': 'mautic.core.id',
'class': 'col-page-id visible-md visible-lg',
}) }}
</tr>
</thead>
<tbody>
{% for i in items %}
{% set item = i[0] %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': securityHasEntityAccess(permissions['page:pages:editown'], permissions['page:pages:editother'], item.createdBy),
'clone': permissions['page:pages:create'],
'delete': securityHasEntityAccess(permissions['page:pages:deleteown'], permissions['page:pages:deleteother'], item.createdBy),
},
'routeBase': 'page',
'nameGetter': 'getTitle',
'customButtons': {
'preview': {
'attr': {
'class': 'btn btn-ghost btn-sm btn-nospin',
'href': path('mautic_page_preview', {'id': item.id}),
'target': '_blank',
'data-toggle': '',
},
'iconClass': 'ri-external-link-line',
'btnText': 'mautic.core.open_link'|trans,
'priority': 100
},
'results': {
'attr': {
'class': 'btn btn-ghost btn-sm btn-nospin',
'href': path('mautic_page_results', {'objectId': item.id}),
'data-toggle': 'ajax',
'data-menu-link': 'mautic_form_index'
},
'iconClass': 'ri-bar-chart-line',
'btnText': 'mautic.form.form.results'|trans,
'priority': 80
}
}|merge(not item.isPreferenceCenter ? {
'copy': {
'attr': {
'data-copy': url('mautic_page_public', {'slug': item.alias}),
'data-toggle': 'none',
},
'btnText': 'mautic.core.copy_page_link'|trans,
'iconClass': 'ri-clipboard-line',
'priority': 90
}
} : {})
}) }}
</td>
<td>
{{ include('@MauticCore/Helper/publishstatus_icon.html.twig', {'item': item, 'model': 'page.page'}) }}
<a href="{{ path('mautic_page_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
{{ item.title }} ({{ item.alias }})
{% if item.isVariant or item.isTranslation or item.isPreferenceCenter or pageConfig.isDraftEnabled %}
<span>
{% if item.isVariant %}
<span data-toggle="tooltip" title="{{ 'mautic.core.icon_tooltip.ab_test'|trans }}">
<i class="ri-fw ri-organization-chart"></i>
</span>
{% endif %}
{% if item.isTranslation %}
<span data-toggle="tooltip" title="{{ 'mautic.core.icon_tooltip.translation'|trans }}">
<i class="ri-fw ri-translate"></i>
</span>
{% endif %}
{% if item.isPreferenceCenter %}
<span data-toggle="tooltip" title="{{ 'mautic.core.icon_tooltip.preference_center'|trans }}">
<i class="ri-settings-5-line"></i>
</span>
{% endif %}
{% if pageConfig.isDraftEnabled and item.hasDraft %}
<span data-toggle="tooltip" title="{{ 'mautic.email.icon_tooltip.has_draft'|trans }}">
<i class="fa fa-fw fa-file"></i>
</span>
{% endif %}
</span>
{% endif %}
</a>
{{ customContent('page.name', _context) }}
{{ include('@MauticProject/Modules/projects.html.twig') }}
</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.category}) }}
</td>
<td class="visible-md visible-lg">
{% if item.template %}
{{ getThemeName(item.template) }}
{% else %}
<span class="text-muted">{{ 'mautic.core.form.default'|trans }}</span>
{% endif %}
</td>
<td class="visible-md visible-lg">{{ item.hits }}</td>
<td class="visible-md visible-lg" title="{% if item.dateAdded %}{{ dateToFullConcat(item.dateAdded) }}{% endif %}">
{% if item.dateAdded %}{{ dateToDate(item.dateAdded) }}{% endif %}
</td>
<td class="visible-md visible-lg" title="{% if item.dateModified %}{{ dateToFullConcat(item.dateModified) }}{% endif %}">
{% if item.dateModified %}{{ dateToDate(item.dateModified) }}{% endif %}
</td>
<td class="visible-md visible-lg">{{ item.createdByUser }}</td>
<td class="visible-md visible-lg">
<a href="{{ path('mautic_page_results', {'objectId': item.id}) }}" data-toggle="ajax" data-menu-link="mautic_form_index" size="sm" class="label label-gray" {% if 0 == i.submission_count %}disabled="disabled"{% endif %}>
{{- 'mautic.form.form.viewresults'|trans({'%count%': i.submission_count}) -}}
</a>
</td>
<td class="visible-md visible-lg">{{ item.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'menuLinkId': 'mautic_page_index',
'baseUrl': path('mautic_page_index'),
'sessionVar': 'page',
}) }}
</div>
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig') }}
{% 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': 'landing-page',
'size': '80'
} %}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.page.contentblock.heading',
subheading: 'mautic.page.contentblock.subheading',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,367 @@
{#
Variables
- activePage (\Mautic\PageBundle\Entity\Page)
- variants
- translations
- permissions
- stats
- abTestResults
- security
- pageUrl
- previewUrl
- logs
- dateRangeForm
@todo - add landing page stats/analytics
#}
{# Only show A/B test button if not already a translation of an a/b test #}
{% set allowAbTest = (activePage.isPreferenceCenter or (activePage.isTranslation(true) and translations.parent.isVariant)) ? false : true %}
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}page{% endblock %}
{% block preHeader %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'item': activePage,
'customButtons': customButtons|default([]),
'templateButtons': {
'close': securityHasEntityAccess( permissions['page:pages:viewown'], permissions['page:pages:viewother'], activePage.createdBy),
},
'routeBase': 'page',
'targetLabel': 'mautic.page.pages'|trans
}) }}
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': activePage.category}) }}
{{ include('@MauticProject/Modules/projects.html.twig', {'item': activePage}) }}
{% endblock %}
{% block headerTitle %}{{ activePage.title }}{% endblock %}
{% block actions %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'item': activePage,
'customButtons': customButtons|default([]),
'templateButtons': {
'edit': securityHasEntityAccess(permissions['page:pages:editown'], permissions['page:pages:editother'], activePage.createdBy),
'abtest': allowAbTest and permissions['page:pages:create'],
'clone': permissions['page:pages:create'],
'delete': securityHasEntityAccess(permissions['page:pages:deleteown'], permissions['page:pages:deleteown'], activePage.createdBy),
},
'routeBase': 'page',
}) }}
{% endblock %}
{% block publishStatus %}
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': activePage,
'status': 'available'
}) -}}
<div class="label__divider"></div>
{% set blueTags = [] %}
{% set grayTags = [] %}
{% if activePage.isPreferenceCenter %}
{% set blueTags = blueTags|merge([{ type: 'read-only', color: 'blue', icon_only: true, label: 'mautic.email.form.preference_center', icon: 'ri-equalizer-2-fill' }]) %}
{% endif %}
{% if activePage.isTranslation and not activePage.isTranslation(true) %}
{% set blueTags = blueTags|merge([{ color: 'blue', label: 'mautic.core.icon_tooltip.translation', icon: 'ri-translate', icon_only: true }]) %}
{% endif %}
{% if activePage.noIndex is defined and activePage.noIndex == 1 %}
{% set blueTags = blueTags|merge([{ label: 'mautic.core.tag.search_index.disabled'|trans, icon: 'ri-eye-off-fill', color: 'blue', icon_only: true }]) %}
{% endif %}
{% if activePage.isVariant and not activePage.isVariant(true) %}
{% set grayTags = grayTags|merge([{ type: 'read-only', color: 'warm-gray', label: 'mautic.email.icon_tooltip.abtest' }]) %}
{% endif %}
{% if activePage.isVariant(true) %}
{% set grayTags = grayTags|merge([{ color: 'warm-gray', label: 'mautic.core.variant_of'|trans({'%parent%' : variants.parent.getName()}), icon: 'ri-organization-chart' }]) %}
{% endif %}
{% if activePage.isTranslation(true) %}
{% set grayTags = grayTags|merge([{
color: 'warm-gray',
label: 'mautic.core.translation_of'|trans({'%parent%' : translations.parent.getName()}),
icon: 'ri-translate',
attributes: {
'href': path('mautic_page_action', {'objectAction': 'view', 'objectId': translations.parent.id})
}
}]) %}
{% endif %}
{% if activePage.language is defined and activePage.language is not empty %}
{% set grayTags = grayTags|merge([{ label: activePage.language|language_name, icon: 'ri-translate-2', color: 'warm-gray', attributes: { 'data-toggle': 'tooltip', 'data-placement': 'top', 'title': 'mautic.core.language'|trans } }]) %}
{% endif %}
{% include '@MauticCore/Helper/_tag.html.twig' with { tags: blueTags|merge(grayTags) } %}
{% endblock %}
{% block content %}
{% set variantContent = include('@MauticCore/Variant/index.html.twig', {
'activeEntity': activePage,
'variants': variants,
'abTestResults': abTestResults,
'model': 'page',
'actionRoute': 'mautic_page_action',
'nameGetter': 'getTitle',
})|trim %}
{% set showVariants = variantContent is not empty %}
{% set translationContent = include('@MauticCore/Translation/index.html.twig', {
'activeEntity': activePage,
'translations': translations,
'model': 'page',
'actionRoute': 'mautic_page_action',
'nameGetter': 'getTitle',
})|trim %}
{% set showTranslations = translationContent is not empty %}
<!-- start: box layout -->
<div class="box-layout">
<!-- left section -->
<div class="col-md-9 height-auto">
<div>
<!-- page detail header -->
{% include '@MauticCore/Helper/description--expanded.html.twig' with {
'description': activePage.metaDescription
} %}
<!--/ page detail header -->
<!-- page detail collapseable -->
<div class="collapse pr-md pl-md" id="page-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table table-hover mb-0">
<tbody>
{{ include('@MauticCore/Helper/details.html.twig', {'entity': activePage}, with_context=false) }}
</tbody>
</table>
</div>
</div>
</div>
<!--/ page detail collapseable -->
</div>
<div>
<!-- page detail collapseable toggler -->
<div class="hr-expand nm">
<span data-toggle="tooltip" title="Detail">
<a href="javascript:void(0)" class="arrow text-secondary collapsed" data-toggle="collapse" data-target="#page-details">
<span class="caret"></span> {{ 'mautic.core.details'|trans }}
</a>
</span>
</div>
<!--/ page detail collapseable toggler -->
<!-- some stats -->
<div class="pa-md">
<div class="row">
<div class="col-sm-12">
<div class="panel">
<div class="panel-body box-layout">
<div class="col-md-3 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
<span class="ri-line-chart-fill"></span>
{{ 'mautic.page.pageviews'|trans }}
</h5>
</div>
<div class="col-md-9 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.pageviews, 'chartType': 'line', 'chartHeight': 300}) }}
</div>
</div>
</div>
</div>
</div>
<!--/ stats -->
{{ customContent('details.stats.graph.below', _context) }}
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
{% if showVariants %}
<li class="active">
<a href="#variants-container" role="tab" data-toggle="tab">
{{- 'mautic.core.variants'|trans -}}
</a>
</li>
{% endif %}
{% if showTranslations %}
<li class="{% if not showVariants %}active{% endif %}">
<a href="#translation-container" role="tab" data-toggle="tab">
{{- 'mautic.core.translations'|trans -}}
</a>
</li>
{% endif %}
</ul>
<!--/ tabs controls -->
</div>
{% if showVariants or showTranslations %}
<!-- start: tab-content -->
<div class="tab-content pa-md">
{% if showVariants %}
<!-- #variants-container -->
<div class="tab-pane active bdr-w-0" id="variants-container">
{{ variantContent|raw }}
</div>
<!--/ #variants-container -->
{% endif %}
<!-- #translation-container -->
{% if showTranslations %}
<div class="tab-pane {% if not showVariants %}active{% endif %} bdr-w-0" id="translation-container">
{{ translationContent|raw }}
</div>
{% endif %}
<!--/ #translation-container -->
</div>
<!--/ end: tab-content -->
{% endif %}
{% set additionalCards = [{
heading: 'mautic.page.results.view',
copy: (not showVariants and not showTranslations) ? 'mautic.page.results.view.description' : null,
pictogram: 'data--set',
href: path('mautic_page_results', {'objectId': activePage.id}),
attributes: {
'data-toggle': 'ajax',
'data-menu-link': 'mautic_form_index'
}
}] %}
{{ include('@MauticCore/Modules/suggested-actions.html.twig', {
'entity': activePage,
'routeBase': 'page',
'showVariants': showVariants,
'showTranslations': showTranslations,
'allowAbTest': allowAbTest,
'additionalCards': additionalCards
}) }}
</div>
<!--/ left section -->
<!-- right section -->
<div class="col-md-3 bdr-l height-auto">
<!-- preview URL -->
{% if not activePage.isPreferenceCenter %}
<div class="panel shd-none bdr-rds-0 bdr-w-0 mt-sm mb-0">
<div class="panel-body pt-xs">
{% include '@MauticCore/Components/card.html.twig' with {
type: 'link',
href: pageUrl,
ctaType: 'new tab',
heading: 'mautic.core.open_link',
attributes: {
'target': '_blank'
}
} %}
</div>
</div>
{% endif %}
<div class="panel shd-none bdr-rds-0 bdr-w-0 mt-sm mb-0">
<div class="panel-heading">
<div class="panel-title">{{ 'mautic.page.preview.url'|trans }}</div>
</div>
<div class="panel-body pt-xs">
{% if previewSettingsForm.translation is defined %}
<div class="row">
<div class="form-group col-xs-12 ">
<div class="control-label">{{ 'mautic.email.preview.show.translation'|trans }}</div>
{{ form_widget(previewSettingsForm.translation) }}
</div>
</div>
{% endif %}
{% if previewSettingsForm.variant is defined %}
<div class="row">
<div class="form-group col-xs-12 ">
<div class="control-label">{{ 'mautic.email.preview.show.ab.variant'|trans }}</div>
{{ form_widget(previewSettingsForm.variant) }}
</div>
</div>
{% endif %}
{% if previewSettingsForm.contact is defined %}
<div class="row">
<div class="form-group col-xs-12 ">
<div class="control-label">{{ 'mautic.page.preview.show.contact'|trans }}</div>
{{ form_widget(previewSettingsForm.contact) }}
</div>
</div>
{% endif %}
<div class="row">
<div class="form-group col-xs-12 ">
<div class="input-group">
<div class="input-group-addon">
{{- include('@MauticCore/Helper/publishstatus_icon.html.twig', {
'item' : activePage,
'model' : 'page',
'query' : 'customToggle=publicPreview'
}) -}}
</div>
<input id="content_preview_url"
data-route="page/preview"
onclick="this.setSelectionRange(0, this.value.length);"
type="text"
class="form-control"
readonly
value="{{ previewUrl|e }}"/>
<span class="input-group-btn">
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.core.open_link',
variant: 'ghost',
icon_only: true,
icon: 'ri-external-link-line',
onclick: 'window.open("' ~ previewUrl ~ '", "_blank");',
attributes: {
'id': 'content_preview_url_button',
'class': 'btn-nospin'
}
}
]
} %}
</span>
<input type="hidden" id="content_preview_settings_object_id" value="{{ activePage.id }}">
<input type="hidden" id="content_preview_settings_contact_id" value="">
</div>
</div>
</div>
</div>
</div>
{% if draftPreviewUrl is not empty %}
<div class="panel bg-transparent shd-none bdr-rds-0 bdr-w-0 mt-sm mb-0">
<div class="panel-heading">
<div class="panel-title">{{ 'mautic.email.draft.preview.url'|trans }}</div>
</div>
<div class="panel-body pt-xs">
<div class="input-group">
<input onclick="this.setSelectionRange(0, this.value.length);" type="text" class="form-control"
readonly
value="{{ draftPreviewUrl|e }}"/>
<span class="input-group-btn">
<button class="btn btn-default btn-nospin"
onclick="window.open('{{ draftPreviewUrl }}', '_blank');">
<i class="fa fa-external-link"></i>
</button>
</span>
</div>
</div>
</div>
{% endif %}
<!--/ preview URL -->
<hr class="hr-w-2" style="width:50%">
<!-- recent activity -->
{{ include('@MauticCore/Helper/recentactivity.html.twig', {'logs': logs}, with_context=false) }}
</div>
<!--/ right section -->
</div>
<!--/ end: box layout -->
{% endblock %}

View File

@@ -0,0 +1,152 @@
{#
Variables
- form
- isVariant
- tokens
- activePage
- themes
- permissions
- previewUrl (optional)
- security (optional)
- Defined when editing page
#}
{% form_theme form with [
'@MauticPage/FormTheme/Page/_page_abtest_settings_properties_row.html.twig',
'@MauticPage/FormTheme/Page/_page_variantSettings_properties_row.html.twig',
] %}
{% set isExisting = activePage.id %}
{% set variantParent = activePage.variantParent %}
{% set previewUrl = previewUrl|default('') %}
{% set draftPreviewUrl = draftPreviewUrl|default('') %}
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}page{% endblock %}
{% block headerTitle %}
{%- if isExisting -%}
{{ 'mautic.page.header.edit'|trans({'%name%': activePage.title}) }}
{%- else -%}
{{ 'mautic.page.header.new'|trans }}
{%- endif -%}
{% if variantParent %}
<div><span class="small">{{ 'mautic.core.variant_of'|trans({'%name%': activePage.title, '%parent%': variantParent.title}) }}</span></div>
{% elseif activePage.isVariant(false) %}
<div><span class="small">{{ 'mautic.page.form.has_variants'|trans }}</span></div>
{%- endif -%}
{% endblock %}
{% block content %}
<div id="grapesjsbuilder_assets" class="hide"></div>
{% set template, attr = form.template.vars.data, form.vars.attr %}
{% set attr = attr|merge({
'data-submit-callback-async': 'clearThemeHtmlBeforeSave',
}) %}
{{ form_start(form, {'attr': attr}) }}
<!-- start: box layout -->
<div class="box-layout">
<!-- container -->
<div class="col-md-9 height-auto">
<div class="row">
<div class="col-xs-12">
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active">
<a href="#theme-container" role="tab" data-toggle="tab">{{ 'mautic.core.form.theme'|trans }}</a>
</li>
<li id="advanced-tab" class="hidden">
<a href="#advanced-container" role="tab" data-toggle="tab">{{ 'mautic.core.advanced'|trans }}</a>
</li>
</ul>
<!--/ tabs controls -->
<div class="tab-content pa-md">
<div class="tab-pane fade in active bdr-w-0" id="theme-container">
<div class="row">
<div class="col-md-12">
{{ form_row(form.template) }}
</div>
</div>
{{ include('@MauticCore/Helper/theme_select.html.twig', {
'type': 'page',
'themes': themes,
'active': form.template.vars.value,
}, with_context=false) }}
</div>
<div class="tab-pane fade bdr-w-0" id="advanced-container">
<br>
<div class="row hidden" id="custom-html-row">
<div class="col-md-12">
{{ form_label(form.customHtml) }}
{{ form_widget(form.customHtml) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 height-auto bdr-l">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.title) }}
{% if isVariant %}
{{ form_row(form.variantSettings) }}
{% else %}
{{ form_row(form.alias) }}
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.language) }}
{{ form_row(form.translationParent) }}
{% endif %}
{{ form_row(form.isPublished, {
'attr': {
'data-none': 'mautic.core.form.unavailable_regardless_of_scheduling',
'data-start': 'mautic.core.form.available_on_scheduled_date',
'data-both': 'mautic.core.form.available_during_scheduled_period',
'data-end': 'mautic.core.form.available_until_scheduled_end'
}
}) }}
{% if (permissions['page:preference_center:editown'] or permissions['page:preference_center:editother']) and not activePage.isVariant %}
{{ form_row(form.isPreferenceCenter) }}
{% endif %}
{{ form_row(form.publishUp, {'label': 'mautic.core.form.available.available_from'}) }}
{{ form_row(form.publishDown, {'label': 'mautic.core.form.available.unavailable_from'}) }}
{% if not isVariant %}
{{ form_row(form.redirectType) }}
{{ form_row(form.redirectUrl) }}
{% endif %}
{{ form_row(form.noIndex) }}
<div class="template-fields {% if not template %}hide{% endif %}">
{{ form_row(form.metaDescription) }}
</div>
<div class="template-fields {% if not template %}hide{% endif %}">
{{ form_row(form.headScript) }}
</div>
<div class="template-fields {% if not template %}hide{% endif %}">
{{ form_row(form.footerScript) }}
</div>
<div class="hide">
{{ form_rest(form) }}
</div>
</div>
</div>
</div>
{{ form_end(form) }}
{{ include('@MauticCore/Helper/builder.html.twig', {
'type': 'page',
'isCodeMode': ('mautic_code_mode' is same as activePage.template),
'objectId': activePage.sessionId,
'previewUrl': previewUrl,
'draftPreviewUrl': draftPreviewUrl,
}, with_context=false) }}
{% endblock %}

View File

@@ -0,0 +1,98 @@
{#
Variables
- searchValue
- items
- categories
- page
- limit
- permissions
- model
- tmpl
- security
#}
{% set isIndex = 'index' == tmpl ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}page{% endblock %}
{% block headerTitle %}{{ 'mautic.page.pages'|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.page.help.searchcommands',
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['page:pages:create'],
},
'routeBase': 'page',
},
'bulk_actions': {
'routeBase': 'page',
'templateButtons': {
'delete': permissions['page:pages:deleteown'] or permissions['page:pages:deleteother'],
},
},
'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'
},
{
'search': 'mautic.page.searchcommand.isexpired',
'label': 'mautic.core.form.expired',
'tooltip': 'mautic.page.searchcommand.isexpired.description',
'icon': 'ri-time-line'
},
{
'search': 'mautic.page.searchcommand.ispending',
'label': 'mautic.core.form.pending',
'tooltip': 'mautic.page.searchcommand.ispending.description',
'icon': 'ri-timer-line'
},
{
'search': 'mautic.page.searchcommand.isprefcenter',
'label': 'mautic.page.searchcommand.isprefcenter.label',
'tooltip': 'mautic.page.searchcommand.isprefcenter.description',
'icon': 'ri-settings-4-line'
}
]
}) }}
<div class="page-list">
{% endif %}
{{ include('@MauticPage/Page/_list.html.twig') }}
{% if isIndex %}
</div>
</div>
{{ include('@MauticCore/Modules/protip.html.twig', {
tip: random(['mautic.protip.pages.mobile', 'mautic.protip.pages.forms'])
}) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,95 @@
{#
Variables
- activePage
- items
#}
{% set pageId = activePage.id %}
<div class="table-responsive table-responsive-force">
<table class="table table-hover pageresult-list" id="pageResultsTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'pageresult.' ~ pageId,
'orderBy': 's.id',
'text': 'mautic.form.report.submission.id',
'class': 'col-pageresult-id',
'filterBy': 's.id',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'pageresult.' ~ pageId,
'orderBy': 's.lead_id',
'text': 'mautic.lead.report.contact_id',
'class': 'col-pageresult-lead-id',
'filterBy': 's.lead_id',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'pageresult.' ~ pageId,
'orderBy': 's.form_id',
'text': 'mautic.form.report.form_id',
'class': 'col-pageresult-form-id',
'filterBy': 's.form_id',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'pageresult.' ~ pageId,
'orderBy': 's.date_submitted',
'text': 'mautic.form.result.thead.date',
'class': 'col-pageresult-date',
'default': true,
'filterBy': 's.date_submitted',
'dataToggle': 'date',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'pageresult.' ~ pageId,
'orderBy': 'i.ip_address',
'text': 'mautic.core.ipaddress',
'class': 'col-pageresult-ip',
'filterBy': 'i.ip_address',
}) }}
</tr>
</thead>
<tbody>
{% if items|length > 0 %}
{% for item in items %}
<tr>
<td>{{ item.id|e }}</td>
<td>
{% if item.leadId is defined %}
<a href="{{ path('mautic_contact_action', {'objectAction': 'view', 'objectId': item.leadId}) }}" data-toggle="ajax">
{{- item.leadId|e -}}
</a>
{% endif %}
</td>
<td>
{% if item.formId is defined %}
<a href="{{ path('mautic_form_action', {'objectAction': 'view', 'objectId': item.formId}) }}" data-toggle="ajax">
{{- item.formId|e -}}
</a>
{% endif %}
</td>
<td>{{ dateToFull(item.dateSubmitted, 'UTC') }}</td>
<td>{{ item.ipAddress|e }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">
{{ include('@MauticCore/Helper/noresults.html.twig') }}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': totalCount,
'page': page,
'limit': limit,
'baseUrl': path('mautic_page_results', {'objectId': activePage.id}),
'sessionVar': 'pageresult.' ~ pageId,
}) }}
</div>

View File

@@ -0,0 +1,39 @@
{#
Variables
- pageTitle
- results
- page
#}
{% set contentOnly = true %}
{% extends '@MauticCore/Default/content.html.twig' %}
{% block pageTitle %}{{ pageTitle }}{% endblock %}
{% block headerTitle %}{{ 'mautic.page.result.header.index'|trans({'%name%': page.getName()}) }}{% endblock %}
{% block content %}
<div class="pageresults">
<table class="table table-hover pageresult-list">
<thead>
<tr>
<th class="col-pageresult-id">{{ 'mautic.form.report.submission.id'|trans }}</th>
<th class="col-pageresult-leadId">{{ 'mautic.lead.report.contact_id'|trans }}</th>
<th class="col-pageresult-formId">{{ 'mautic.form.report.form_id'|trans }}</th>
<th class="col-pageresult-date">{{ 'mautic.form.result.thead.date'|trans }}</th>
<th class="col-pageresult-ip">{{ 'mautic.core.ipaddress'|trans }}</th>
</tr>
</thead>
<tbody>
{% for item in results %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.leadId }}</td>
<td>{{ item.formId }}</td>
<td>{{ dateToFull(item.dateSubmitted, 'UTC') }}</td>
<td>{{ item.ipAddress }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{#
Variables
- activePage
- items
#}
{% set isIndex = 'index' == tmpl ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}pageresult{% endblock %}
{% block headerTitle %}
{{ 'mautic.page.result.header.index'|trans({
'%name%': activePage.getName(),
}) }}
{% endblock %}
{% block actions %}
{% set buttons = [
{
'attr': {
'target': '_new',
'data-toggle': '',
'class': 'btn btn-ghost btn-nospin',
'href': path('mautic_page_export', {'objectId': activePage.id, 'format': 'html'}),
},
'btnText': 'mautic.form.result.export.html'|trans,
'iconClass': 'ri-file-code-line',
'primary': true,
},
{
'attr': {
'data-toggle': '',
'class': 'btn btn-ghost btn-nospin',
'href': path('mautic_page_export', {'objectId': activePage.id, 'format': 'csv'}),
},
'btnText': 'mautic.form.result.export.csv'|trans,
'iconClass': 'ri-file-text-line',
'primary': true,
}
] %}
{% if '\\PhpOffice\\PhpSpreadsheet\\Spreadsheet' is class %}
{% set buttons = buttons|merge([{
'attr': {
'data-toggle': '',
'class': 'btn btn-ghost btn-nospin',
'href': path('mautic_page_export', {'objectId': activePage.id, 'format': 'xlsx'}),
},
'btnText': 'mautic.form.result.export.xlsx'|trans,
'iconClass': 'ri-file-excel-2-fill',
'primary': true,
}]) %}
{% endif %}
{% set buttons = buttons|merge([{
'attr': {
'class': 'btn btn-ghost',
'href': path('mautic_page_action', {'objectAction': 'view', 'objectId': activePage.id}),
'data-toggle': 'ajax',
},
'iconClass': 'ri-close-line',
'btnText': 'mautic.core.form.close'|trans,
}]) %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {'customButtons': buttons}) }}
{% endblock %}
{% block content %}
{% if isIndex %}
<div class="page-list">
{% endif %}
{{ include('@MauticPage/Result/_list.html.twig') }}
{% if isIndex %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
{#
Variables
- results
- variants
#}
{% set support = results.support %}
{% set label = variants.criteria[results.basedOn].label %}
{% set chart = barChartInitialize(support.labels) %}
{% if support.data is defined and support.data is iterable %}
{% for datasetLabel, values in support.data %}
{% do chart.setDataset(datasetLabel, values) %}
{% endfor %}
{% endif %}
<div class="panel ovf-h bg-light-xs abtest-bar-chart">
<div class="panel-body box-layout">
<div class="col-xs-8 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
{{ label|trans }}
</h5>
</div>
<div class="col-xs-4 va-t text-right">
<h3 class="text-white dark-sm"><span class="ri-bar-chart-box-line"></span></h3>
</div>
</div>
{{ include('@MauticCore/Helper/chart.html.twig', {'chartData': chart.render, 'chartType': 'bar', 'chartHeight': 300}) }}
</div>

View File

@@ -0,0 +1,22 @@
{#
Variables
- pages
#}
{% set count = pages|length %}
{% if count > 0 %}
<div class="page-lang-bar">
{% for page in pages %}
{% set active = app.request.requestUri == page.url %}
<span>
{% if not active %}
<a href="{{ page.url }}">
{% endif %}
{{ page.lang }}
{% if not active %}
</a>
{% endif %}
</span>
{% set count = count - 1 %}
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,5 @@
{{ assetAddScriptDeclaration('.share-buttons { display: block; }
.share-button { float: left; margin-right: 5px; }
.share-button.facebook-share-button.layout-box_count.action-like iframe { width: 50px !important; }
.share-button.facebook-share-button.layout-box_count { margin-right: 10px !important; }
.share-button.twitter-share-button.layout-horizontal { width: 75px !important; }') }}

View File

@@ -0,0 +1,19 @@
{% if showMore is defined %}
<a href="{{ url('mautic_page_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_page_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
<span class="fw-sb">{{ item.title|purify }}</span>
<span class="ml-4 mr-sm">#{{ item.getId() }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'available',
'simplified': 'true'
}) -}}
<span class="pull-right" data-toggle="tooltip" title="{{ 'mautic.page.hits'|trans }}" data-placement="left">
<i class="ri-eye-line"></i>
{{ item.hits }}
</span>
</a>
{% endif %}

View File

@@ -0,0 +1,100 @@
{% if event.extra is defined %}
{% set timeOnPage = 'mautic.core.unknown'|trans %}
{% if event.extra.hit.dateLeft %}
{% set timeOnPage = (event.extra.hit.dateLeft.timestamp - event.extra.hit.dateHit.timestamp) %}
{# format the time #}
{% if timeOnPage > 60 %}
{% set sec = timeOnPage % 60 %}
{% set min = (timeOnPage / 60)|round(0, 'floor') %}
{% set timeOnPage = min ~ 'm ' ~ sec ~ 's' %}
{% else %}
{% set timeOnPage = timeOnPage ~ 's' %}
{% endif %}
{% endif %}
<dl class="dl-horizontal">
<dt>{{ 'mautic.page.time.on.page'|trans }}:</dt>
<dd>{{ timeOnPage }}</dd>
<dt>{{ 'mautic.page.referrer'|trans }}:</dt>
<dd>{% if event['extra']['hit']['referer'] %}{{ assetMakeLinks(event['extra']['hit']['referer']) }}{% else %}{{ 'mautic.core.unknown'|trans }}{% endif %}</dd>
<dt>{{ 'mautic.page.url'|trans }}:</dt>
<dd>{% if event['extra']['hit']['url'] %}{{ assetMakeLinks(event['extra']['hit']['url']) }}{% else %}{{ 'mautic.core.unknown'|trans }}{% endif %}</dd>
{% if event.extra.hit.device is defined and event.extra.hit.device is not empty %}
<dt>{{ 'mautic.core.timeline.device.name'|trans }}</dt>
<dd class="ellipsis">{{ inputClean(event.extra.hit.device) }}</dd>
{% endif %}
{% if event.extra.hit.deviceOsName is defined and event.extra.hit.deviceOsName is not empty %}
<dt>{{ 'mautic.core.timeline.device.os'|trans }}</dt>
<dd class="ellipsis">{{ inputClean(event.extra.hit.deviceOsName) }}</dd>
{% endif %}
{% if event.extra.hit.deviceBrand is defined and event.extra.hit.deviceBrand is not empty %}
<dt>{{ 'mautic.core.timeline.device.brand'|trans }}</dt>
<dd class="ellipsis">{{ inputClean(event.extra.hit.deviceBrand) }}</dd>
{% endif %}
{% if event.extra.hit.deviceModel is defined and event.extra.hit.deviceModel is not empty %}
<dt>{{ 'mautic.core.timeline.device.model'|trans }}</dt>
<dd class="ellipsis">{{ inputClean(event.extra.hit.deviceModel) }}</dd>
{% endif %}
{% if event.extra.hit.sourceName is defined and event.extra.hit.sourceName is not empty %}
<dt>{{ 'mautic.core.source'|trans }}:</dt>
<dd>
{% if event.extra.hit.sourceRate is defined %}
<a href="{{ inputClean(event.extra.hit.sourceRoute) }}" data-toggle="ajax">{{ inputClean(event.extra.hit.sourceName) }}</a>
{% else %}
{{ inputClean(event.extra.hit.sourceName) }}
{% endif %}
</dd>
{% if event.extra.hit.clientInfo is defined and event.extra.hit.clientInfo is not empty and event.extra.hit.clientInfo is iterable %}
<dt>{{ 'mautic.core.timeline.device.client.info'|trans }}</dt>
<dd class="ellipsis">
{% for clientInfo in event.extra.hit.clientInfo %}
{{ inputClean(clientInfo) }}
{% endfor %}
</dd>
{% endif %}
{% endif %}
{% if event.extra.hit.query is defined and event.extra.hit.query is not empty and event.extra.hit.query is iterable %}
{% set counter = 0 %}
{% for k, v in event.extra.hit.query %}
{% if v is not empty and k not in ['ct', 'page_title', 'page_referrer', 'page_url'] %}
{% if v is iterable %}
{% for k2, v2 in v %}
{% set counter = counter + 1 %}
{% set k2 = k|replace({'_': ' '})|title %}
<dt>{{ k2 }}:</dt>
<dd class="ellipsis">{{ v2 }}</dd>
{% endfor %}
{% else %}
{% set counter = counter + 1 %}
{% set k = k|replace({'_': ' '})|title %}
<dt>{{ k }}</dt>
<dd class="ellipsis">{{ v }}</dd>
{% endif %}
{% if showMore is not defined and counter > 5 %}
{% set showMore = true %}
<div style="display:none">
{% endif %}
{% endif %}
{% endfor %}
{% if showMore is defined and true == showMore %}
</div>
<a href="javascript:void(0);" class="text-center small center-block mt-xs" onclick="Mautic.toggleTimelineMoreVisiblity(mQuery(this).prev());">{{ 'mautic.core.more.show'|trans }}</a>
{% endif %}
{% endif %}
</dl>
<div class="small">
{{ inputClean(event.extra.hit.userAgent) }}
</div>
{% endif %}

View File

@@ -0,0 +1,49 @@
{% set viewTime = 'mautic.core.unknown'|trans %}
{% set duration = 'mautic.core.unknown'|trans %}
{% set percentage = 'mautic.core.unknown'|trans %}
{% set unknown = 'mautic.core.unknown'|trans %}
{% set icon = event.icon|default('') %}
{% if event.extra.hit.time_watched is defined %}
{% set viewTimeActual = event.extra.hit.time_watched %}
{% set viewTime = event.extra.hit.time_watched %}
{# format the time #}
{% if viewTime > 60 %}
{% set sec = viewTime % 60 %}
{% set min = (viewTime / 60)|round(0, 'floor') %}
{% set viewTime = min ~ 'm ' ~ sec ~ 's' %}
{% else %}
{% set viewTime = viewTime ~ 's' %}
{% endif %}
{% endif %}
{% if event.extra.hit.duration is defined %}
{% set durationActual = event.extra.hit.duration %}
{% set duration = event.extra.hit.duration %}
{# format the time #}
{% if duration > 60 %}
{% set sec = duration % 60 %}
{% set min = (duration / 60)|round(0, 'floor') %}
{% set duration = min ~ 'm ' ~ sec ~ 's' %}
{% else %}
{% set duration = duration ~ 's' %}
{% endif %}
{% endif %}
{% if viewTime != unknown and duration != unknown %}
{% set percentage = ((viewTimeActual / durationActual) * 100)|round %}
{% endif %}
<dl class="dl-horizontal">
<dt>{{ 'mautic.page.time.on.video'|trans }}:</dt>
<dd class="ellipsis">{{ 'mautic.page.time.on.video.value'|trans({'%time_watched%': viewTime, '%duration%': duration, '%percentage%': percentage}) }}</dd>
<dt>{{ 'mautic.page.referrer'|trans }}:</dt>
<dd class="ellipsis">{% if event.extra.hit.referer is defined and event.extra.hit.referer is not empty %}{{ assetMakeLinks(event['extra']['hit']['referer']) }}{% else %}{{ 'mautic.core.unknown'|trans }}{% endif %}</dd>
<dt>{{ 'mautic.video.url'|trans }}:</dt>
<dd class="ellipsis">{% if event.hit.url is defined and event.hit.url is not empty %}{{ assetMakeLinks(event['extra']['hit']['url']) }}{% else %}{{ 'mautic.core.unknown'|trans }}{% endif %}</dd>
</dl>
<div class="small">
{{ inputClean(event.extra.hit.user_agent) }}
</div>

Some files were not shown because too many files have changed in this diff Show More