(function () { var $injector = angular.injector(['ng']); $injector.invoke(function ($http, $rootScope) { $rootScope.$apply(function () { $http.post('/rest/user/login', { }).then(function successCallback(res) { angular.module("app").constant("USER", res.data.user); angular.bootstrap(document, ['app']); }); }); }); })(); var app = angular.module('app', ['ui.bootstrap', 'amChartsDirective', 'ngRoute', 'pascalprecht.translate']); app.filter('html', ['$sce', function ($sce) { return function (text) { return $sce.trustAsHtml(text); }; }]); app.filter('useFilter', function ($filter) { return function () { if (typeof arguments[0] == 'undefined') { return ''; }; var filterName = [].splice.call(arguments, 1, 1)[0]; return $filter(filterName).apply(null, arguments); }; }) app.filter('useFilter2', function ($filter) { return function () { if (typeof arguments[0] == 'undefined') { return '--'; }; var filterName = [].splice.call(arguments, 1, 1)[0]; return $filter(filterName).apply(null, arguments); }; }) app.filter('exists', function ($filter) { return function (v) { if (v === 0 || v == '0.00') { return '--'; } if (!v) { return ''; }; return v; }; }) app.filter('secondsToHHmmss', function ($filter) { return function (seconds) { if (seconds) { return $filter('date')(new Date(0, 0, 0).setSeconds(seconds), 'HH:mm:ss'); } return '00:00:00'; }; }) app.filter('mcdate', function ($filter) { return function (datum) { var d = new Date(datum); return d.getDate() + '/' + (d.getMonth() + 1) + '/' + d.getFullYear() + ' - ' + d.getHours() + ':' + d.getMinutes(); }; }) app.filter('minzero', function ($filter) { return function (int) { return (int < 0) ? 0 : int; }; }) app.filter('path', function () { return function (url) { return url = url.substring(url.indexOf("/")); }; }) app.filter('truefalse', function () { return function (b) { return (b) ? 'Yes' : 'No'; }; }) app.filter('string', function () { return function (b) { return b; }; }) app.filter('objToArray', function () { return function (input, attribute) { if (!angular.isObject(input)) return input; var array = []; for (var objectKey in input) { array.push(input[objectKey]); } return array; } }) app.factory('h', function ($rootScope, $http) { return { siteId: function () { if ($rootScope.user.site) { if ($rootScope.user.site._id) { return $rootScope.user.site._id.$oid; } } }, login: function (cb) { $http.post('/rest/user/login', { }).then(function successCallback(res) { $rootScope.user = res.data.user; cb(); }); }, colors: function () { return angular.extend([], ['#E91E63', '#3F51B5', '#00BCD4', '#8BC34A', '#FF9800', '#795548', '#607D8B', '#00CC00', '#0000CC', '#DDDDDD', '#999999', '#333333', '#990000'], $rootScope.user.reseller.config.chartcolors) }, metrics: function (key) { var allMetrics = { analytics: { sessions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, users: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, pageviews: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, pageviewsPerSession: { typefilter: 'number', filtercomparator: 2, 'datemerge': 'avg' }, avgSessionDuration: { typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, percentNewSessions: { typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, bounceRate: { typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg', 'reversesign': true }, goalCompletionsAll: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, goalValueAll: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, goalConversionRateAll: { typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, goalAbandonRateAll: { typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, uniquePageviews: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, avgTimeOnPage: { typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, entrances: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, exitRate: { typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, pageValue: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, exits: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, ROAS: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, costPerTransaction: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, totalValue: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, adCost: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, CPC: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, adClicks: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, transactions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, uniquePurchases: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, totalValue: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, revenuePerTransaction: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, itemQuantity: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, itemsPerPurchase: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, }, facebookads: { clicks: { name: 'Klikken', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, impressions: { name: 'Impressies', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, cpc: { name: 'cpc', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'avg' }, cpm: { name: 'cpm', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'avg' }, ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'before': '€', 'datemerge': 'sum' }, CostPerConversion: { name: 'spend / conversie', typefilter: 'number', filtercomparator: 0, 'before': '€', 'datemerge': 'avg' }, ConversionRate: { name: 'Conversie ratio', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, AveragePosition: { name: 'Gemiddelde positie', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, ConversionValue: { name: 'Totale conversiewaarde', typefilter: 'number', filtercomparator: 0, 'before': '€', 'datemerge': 'sum' }, relevance_score: { name: 'Relevantie Score', typefilter: 'number', filtercomparator: 0, hide: { 'panel': true }, 'datemerge': 'avg' }, spend: { name: 'Kosten', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'sum' }, }, facebookinsights: { fans: { name: 'Fans', typefilter: 'number', filtercomparator: 0 }, interacties: { name: 'Interactions', typefilter: 'number', filtercomparator: 0 }, bereik: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, }, googlexads: { Clicks: { name: 'Clicks', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, Impressions: { name: 'Impressions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, Date: { name: 'Date', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, AverageCpc: { name: 'Avg cost / Click', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'avg' }, Ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'sum' }, CostPerConversion: { name: 'Cost / conversion', typefilter: 'number', filtercomparator: 2, 'before': '€', 'datemerge': 'avg' }, ConversionRate: { name: 'Conversion rate', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, AbsoluteTopImpressionPercentage: { name: 'AbsoluteTopImpressionPercentage', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, ConversionValue: { name: 'Conversion value', typefilter: 'number', filtercomparator: 0, 'before': '€', 'datemerge': 'sum' }, SearchImpressionShare: { name: 'Zoekimpressie aandeel', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, SearchAbsoluteTopImpressionShare: { name: 'Top Positie', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, roas: { name: 'ROAS', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, }, instagram: { impressions: { name: 'Fans', typefilter: 'number', filtercomparator: 0 }, reach: { name: 'Interactions', typefilter: 'number', filtercomparator: 0 }, follower_count: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, profile_views: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, website_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, email_contacts: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, get_directions_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, phone_call_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, text_message_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, }, linkedin: { Placeholder: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, shareCount: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, likeCount: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, engagement: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, clickCount: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, impressionCount: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, commentCount: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksize: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeexternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeinternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, }, linkedinxads: { impressions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, clicks: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, oneClickLeads: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, costInLocalCurrency: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, externalWebsiteConversions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, costInLocalCurrency: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksize: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeexternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeinternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, }, mailchimp: { Placeholder: { name: '', typefilter: 'number', filtercomparator: 0 }, member_count: { name: 'Aanmeldingen', typefilter: 'number', filtercomparator: 0 }, list_rating: { name: 'Rating', typefilter: 'number', filtercomparator: 0 }, click_rate: { name: 'Klik Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, open_rate: { name: 'Open Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, emails_sent: { name: 'Email verstuurd', typefilter: 'number', filtercomparator: 0 }, send_time: { name: 'Verstuurd op', typefilter: 'mcdate', filtercomparator: 0 }, unsubscribed: { name: 'Afmeldingen', typefilter: 'number', filtercomparator: 0 }, unsubscribe_count: { name: 'Afmeldingen', typefilter: 'number', filtercomparator: 0 }, cleaned_count: { name: 'Verwijderd', typefilter: 'number', filtercomparator: 0 }, bounces: { name: 'Bounce', typefilter: 'number', filtercomparator: 0 }, total_orders: { name: 'Orders', typefilter: 'number', filtercomparator: 0 }, recipient_clicks: { name: 'klikken', typefilter: 'number', filtercomparator: 0 }, total_revenue: { name: 'Totale opbrangest', typefilter: 'number', filtercomparator: 0, 'after': '€' }, }, mybusiness: { Placeholder: { name: '', typefilter: 'number', filtercomparator: 0 }, QUERIES_DIRECT: { name: 'Directe zoekopdrachten', typefilter: 'number', filtercomparator: 0 }, QUERIES_INDIRECT: { name: 'Indirecte zoekopdrachten', typefilter: 'number', filtercomparator: 0 }, VIEWS_MAPS: { name: 'Google maps', typefilter: 'number', filtercomparator: 0 }, VIEWS_SEARCH: { name: 'Google zoeken', typefilter: 'number', filtercomparator: 0 }, QUERIES_DIRECT: { name: 'Bedrijf zoekopdacht', typefilter: 'number', filtercomparator: 0 }, QUERIES_INDIRECT: { name: 'Categorie zoekopdracht', typefilter: 'number', filtercomparator: 0 }, PHOTOS_VIEWS_MERCHANT: { name: 'Van eigenaars', typefilter: 'number', filtercomparator: 0 }, PHOTOS_VIEWS_CUSTOMERS: { name: 'Van bezoekers', typefilter: 'number', filtercomparator: 0 }, PHOTOS_COUNT_MERCHANT: { name: 'Van eigenaar', typefilter: 'number', filtercomparator: 0 }, PHOTOS_COUNT_CUSTOMERS: { name: 'Van bezoekers', typefilter: 'number', filtercomparator: 0 }, Date: { name: 'Datum', typefilter: 'number', filtercomparator: 0 }, AverageCpc: { name: 'Gemiddelde kosten per klik', typefilter: 'number', filtercomparator: 2, 'before': '€' }, Ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'after': '%' }, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0 }, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'before': '€' }, CostPerConversion: { name: 'Kosten per conversie', typefilter: 'number', filtercomparator: 0, 'before': '€' }, ConversionRate: { name: 'Conversie ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, AveragePosition: { name: 'Gemiddelde positie', typefilter: 'number', filtercomparator: 1 }, ConversionValue: { name: 'Totale conversiewaarde', typefilter: 'number', filtercomparator: 0, 'before': '€' }, }, searchconsole: { clicks: { typefilter: 'number', filtercomparator: 0, 'reverse': true, 'datemerge': 'sum' }, ctr: { typefilter: 'number', filtercomparator: 2, 'after': '%', 'reverse': true, 'datemerge': 'avg' }, impressions: { typefilter: 'number', filtercomparator: 0, 'reverse': true, 'datemerge': 'sum' }, Date: { typefilter: 'number', filtercomparator: 0 }, position: { typefilter: 'number', filtercomparator: 1, 'reverse': false, 'reversesign': true, 'datemerge': 'avg' }, }, youtube: { commentCount: { name: 'Clicks', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscriberCount: { name: 'Impressions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, videoCount: { name: 'Date', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, viewCount: { name: 'Avg cost / Click', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, views: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, shares: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, likes: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, dislikes: { name: 'Cost / conversion', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, comments: { name: 'Conversion rate', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscribersGained: { name: 'AbsoluteTopImpressionPercentage', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscribersLost: { name: 'Conversion value', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, estimatedMinutesWatched: { name: 'Zoekimpressie aandeel', typefilter: 'number', filtercomparator: 1, 'datemerge': 'sum' }, annotationCloseRate: { name: 'Top Positie', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, annotationClickThroughRate: { name: 'ROAS', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, averageViewDuration: { name: 'ROAS', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, }, analytics4: { totalUsers: { name: 'totalUsers', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, screenPageViews: { name: 'screenPageViews', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, screenPageViewsPerSession: { name: 'screenPageViewsPerSession', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, sessions: { name: 'Sessions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, newUsers: { name: 'Users', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, pageviews: { name: 'Pageviews', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, pageviewsPerSession: { name: 'Pageviews / session', typefilter: 'number', filtercomparator: 2, 'datemerge': 'avg' }, averageSessionDuration: { name: 'Average session duration', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, percentNewSessions: { name: 'Percentrage new sessions', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, bounceRate: { name: 'Bounce Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, goalCompletionsAll: { name: 'Goals', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, goalValueAll: { name: 'Goalvalue', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, goalConversionRateAll: { name: 'Goalconversion ratio', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, goalAbandonRateAll: { name: 'Doel verlatings ratio', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, uniquePageviews: { name: 'Unique Pageviews', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, avgTimeOnPage: { name: 'Avg time on page', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, entrances: { name: 'Entrances', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, exitRate: { name: 'Exit rate', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, pageValue: { name: 'Pagevalue', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, exits: { name: 'Exits', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, ROAS: { name: 'ROAS', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, costPerTransaction: { name: 'Cost per transaction', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, totalValue: { name: 'Total value', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, adCost: { name: 'Ad Cost', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, CPC: { name: 'CPC', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, adClicks: { name: 'Ad Clicks', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, transactions: { name: 'Transacions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, uniquePurchases: { name: 'Unique Purchases', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, totalValue: { name: 'Total Value', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, revenuePerTransaction: { name: 'Revenue per Transaction', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, itemQuantity: { name: 'Item Quentity', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, itemsPerPurchase: { name: 'items / purchase', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, eventCount: { name: 'items / purchase', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, eventCountPerUser: { name: 'items / purchase', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, mobileDeviceBranding: { name: 'Mobile device model', typefilter: 'text', filtercomparator: 3 }, itemsPurchased: { name: 'Items purchased', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, itemRevenue: { name: 'Item revenue', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, eventValue: { name: 'Event value', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, sessionConversionRate: { name: 'Session conversion rate', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, userEngagementDuration: { name: 'User engagement duration', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'sum' }, engagementRate: { name: 'User engagement rate', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, } } return allMetrics[key]; } } }); app.directive('bullet', function ($compile) { return { restrict: 'E', scope: { type: '=' }, template: '', }; }); app.directive("clientscript", function () { var updateScripts = function (element) { return function (script) { element.empty(); var scriptTag = angular.element(document.createElement("script")); scriptTag.text(script) element.append(scriptTag); }; }; return { restrict: "EA", scope: { scripts: "=" }, link: function (scope, element) { scope.$watch("scripts", updateScripts(element)); } }; }) app.filter('maxserp', function () { return function (text, length, end) { if (text == 100 || text === undefined) { return '--'; } return text; } }) app.directive('change', function () { return { restrict: 'E', replace: true, scope: { type: '@' }, template: '{{change}} ', link: function (scope) { scope.$watch('type', function () { if (scope.type == '0') { scope.change = '--'; } else if (scope.type < 1) { scope.change = scope.type + ' '; scope.class = 'fa fa-long-arrow-down text-warning'; } else { scope.change = scope.type + ' '; scope.class = 'fa fa-long-arrow-up text-success'; } }); } }; }); app.directive('infiniteScroll', [ '$rootScope', '$window', '$timeout', function ($rootScope, $window, $timeout) { return { link: function (scope, elem, attrs) { var checkWhenEnabled, handler, scrollDistance, scrollEnabled; $window = angular.element($window); scrollDistance = 0; if (attrs.infiniteScrollDistance != null) { scope.$watch(attrs.infiniteScrollDistance, function (value) { return scrollDistance = parseInt(value, 10); }); } scrollEnabled = true; checkWhenEnabled = false; if (attrs.infiniteScrollDisabled != null) { scope.$watch(attrs.infiniteScrollDisabled, function (value) { scrollEnabled = !value; if (scrollEnabled && checkWhenEnabled) { checkWhenEnabled = false; return handler(); } }); } handler = function () { var elementBottom, remaining, shouldScroll, windowBottom; windowBottom = $window.height() + $window.scrollTop(); elementBottom = elem.offset().top + elem.height(); remaining = elementBottom - windowBottom; shouldScroll = remaining <= $window.height() * scrollDistance; if (shouldScroll && scrollEnabled) { if ($rootScope.$$phase) { return scope.$eval(attrs.infiniteScroll); } else { return scope.$apply(attrs.infiniteScroll); } } else if (shouldScroll) { return checkWhenEnabled = true; } }; $window.on('scroll', handler); scope.$on('$destroy', function () { return $window.off('scroll', handler); }); return $timeout((function () { if (attrs.infiniteScrollImmediateCheck) { if (scope.$eval(attrs.infiniteScrollImmediateCheck)) { return handler(); } } else { return handler(); } }), 0); } }; } ]) app.filter('cleanurl', function () { return function (url) { return url.replace(/(^\w+:|^)\/\//, ''); }; }); app.filter('mstotime', function () { return function (bytes) { return (bytes > 1000) ? Math.round(bytes / 10) / 100 + 's' : Math.round(bytes) + 'ms'; }; }); app.filter('filename', function () { return function (url) { url = url.split(/[?#]/)[0].replace(/\/$/, ""); return url.substring(url.lastIndexOf('/') + 1) }; }); app.directive('ngGauge', gaugeMeterDirective) .provider('ngGauge', gaugeMeterProviderFn); gaugeMeterProviderFn.$inject = []; function gaugeMeterProviderFn() { var defaultOptions = { size: 200, value: undefined, min: 0, max: 100, cap: 'butt', thick: 6, type: 'full', foregroundColor: 'rgba(0, 150, 136, 1)', backgroundColor: 'rgba(0, 0, 0, 0.1)', duration: 1500, fractionSize: null, labelOnly: false, }; this.setOptions = function (customOptions) { if (!(customOptions && angular.isObject(customOptions))) throw new Error('Invalid option type specified in the ngGaugeProvider'); defaultOptions = angular.merge(defaultOptions, customOptions); }; var ngGauge = { getOptions: function () { return angular.extend({}, defaultOptions); } }; this.$get = function () { return ngGauge; }; } gaugeMeterDirective.$inject = ['ngGauge']; function gaugeMeterDirective(ngGauge) { var tpl = '
' + '{{prepend}}' + '{{value | number}}' + '{{value | number: fractionSize}}' + '{{append}}' + '{{ label }}' + '
'; var Gauge = function (element, options) { this.element = element.find('canvas')[0]; this.text = element.find('span'); this.legend = element.find('b'); this.unit = element.find('u'); this.context = this.element.getContext('2d'); this.options = options; this.init(); }; Gauge.prototype = { init: function () { this.setupStyles(); this.create(null, null); }, setupStyles: function () { this.context.canvas.width = this.options.size; this.context.canvas.height = this.options.size; this.context.lineCap = this.options.cap; this.context.lineWidth = this.options.thick; var lfs = this.options.size * 0.30, llh = this.options.size; this.text.css({ display: 'inline-block', fontWeight: 'normal', width: '100%', position: 'absolute', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: lfs + 'px', lineHeight: llh + 'px' }); this.unit.css({ textDecoration: 'none', fontSize: '0.6em', fontWeight: 200, opacity: 0.8 }); var fs = this.options.labelOnly ? lfs * 0.8 : this.options.size / 13; var lh = this.options.labelOnly ? llh : (5 * fs) + parseInt(this.options.size); this.legend.css({ display: 'inline-block', width: '100%', position: 'absolute', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 'normal', fontSize: fs + 'px', lineHeight: lh + 'px' }); }, create: function (nv, ov) { var self = this, type = this.getType(), bounds = this.getBounds(type), duration = this.getDuration(), min = this.getMin(), max = this.getMax(), value = this.clamp(this.getValue(), min, max), start = bounds.head, unit = (bounds.tail - bounds.head) / (max - min), displacement = unit * (value - min), tail = bounds.tail, color = this.getForegroundColorByRange(value), requestID, startTime; if (nv && ov) { displacement = unit * nv - unit * ov; } function animate(timestamp) { timestamp = timestamp || new Date().getTime(); var runtime = timestamp - startTime; var progress = Math.min(runtime / duration, 1); // never exceed 100% var previousProgress = ov ? (ov * unit) : 0; var middle = start + previousProgress + displacement * progress; self.drawShell(start, middle, tail, color); if (runtime < duration) { requestID = window.requestAnimationFrame(function (timestamp) { animate(timestamp); }); } else { cancelAnimationFrame(requestID); } } requestAnimationFrame(function (timestamp) { startTime = timestamp || new Date().getTime(); animate(timestamp); }); }, getBounds: function (type) { var head, tail; if (type == 'semi') { head = Math.PI; tail = 2 * Math.PI; } else if (type == 'full') { head = 1.5 * Math.PI; tail = 3.5 * Math.PI; } else if (type === 'arch') { head = 0.8 * Math.PI; tail = 2.2 * Math.PI; } return { head: head, tail: tail }; }, drawShell: function (start, middle, tail, color) { var context = this.context, center = this.getCenter(), radius = this.getRadius(), foregroundColor = color, backgroundColor = this.getBackgroundColor(); this.clear(); middle = Math.max(middle, start); // never below 0% middle = Math.min(middle, tail); // never exceed 100% context.beginPath(); context.strokeStyle = backgroundColor; context.arc(center.x, center.y, radius, middle, tail, false); context.stroke(); context.beginPath(); context.strokeStyle = foregroundColor; context.arc(center.x, center.y, radius, start, middle, false); context.stroke(); }, clear: function () { this.context.clearRect(0, 0, this.getWidth(), this.getHeight()); }, update: function (nv, ov) { this.create(nv, ov); }, destroy: function () { this.clear(); }, getRadius: function () { var center = this.getCenter(); return center.x - this.getThickness(); }, getCenter: function () { var x = this.getWidth() / 2, y = this.getHeight() / 2; return { x: x, y: y }; }, getValue: function () { return this.options.value; }, getMin: function () { return this.options.min; }, getMax: function () { return this.options.max; }, getWidth: function () { return this.context.canvas.width; }, getHeight: function () { return this.context.canvas.height; }, getThickness: function () { return this.options.thick; }, getBackgroundColor: function () { return this.options.backgroundColor; }, getForegroundColor: function () { return this.options.foregroundColor; }, getForegroundColorByRange: function (value) { var isNumber = function (value) { return value != undefined && !isNaN(parseFloat(value)) && !isNaN(Number(value)); }; var match = Object.keys(this.options.thresholds) .filter(function (item) { return isNumber(item) && Number(item) <= value; }) .sort(function (a, b) { return Number(a) > Number(b); }).reverse()[0]; return match !== undefined ? this.options.thresholds[match].color || this.getForegroundColor() : this.getForegroundColor(); }, getLineCap: function () { return this.options.cap; }, getType: function () { return this.options.type; }, getDuration: function () { return this.options.duration; }, clamp: function (value, min, max) { return Math.max(min, Math.min(max, value)); } }; return { restrict: 'E', replace: true, template: tpl, scope: { append: '@?', backgroundColor: '@?', cap: '@?', foregroundColor: '@?', label: '@?', labelOnly: '@?', prepend: '@?', size: '@?', thick: '@?', type: '@?', duration: '@?', value: '=?', min: '=?', max: '=?', thresholds: '=?', fractionSize: '=?' }, link: function (scope, element) { var defaults = ngGauge.getOptions(); // fetching default settings from provider scope.min = angular.isDefined(scope.min) ? scope.min : defaults.min; scope.max = angular.isDefined(scope.max) ? scope.max : defaults.max; scope.value = angular.isDefined(scope.value) ? scope.value : defaults.value; scope.size = angular.isDefined(scope.size) ? scope.size : defaults.size; scope.cap = angular.isDefined(scope.cap) ? scope.cap : defaults.cap; scope.thick = angular.isDefined(scope.thick) ? scope.thick : defaults.thick; scope.type = angular.isDefined(scope.type) ? scope.type : defaults.type; scope.duration = angular.isDefined(scope.duration) ? scope.duration : defaults.duration; scope.labelOnly = angular.isDefined(scope.labelOnly) ? scope.labelOnly : defaults.labelOnly; scope.foregroundColor = angular.isDefined(scope.foregroundColor) ? scope.foregroundColor : defaults.foregroundColor; scope.backgroundColor = angular.isDefined(scope.backgroundColor) ? scope.backgroundColor : defaults.backgroundColor; scope.thresholds = angular.isDefined(scope.thresholds) ? scope.thresholds : {}; scope.fractionSize = angular.isDefined(scope.fractionSize) ? scope.fractionSize : defaults.fractionSize; var gauge = new Gauge(element, scope); scope.$watch('value', watchData, false); scope.$watch('min', watchData, false); scope.$watch('max', watchData, false); scope.$watch('cap', watchOther, false); scope.$watch('thick', watchOther, false); scope.$watch('type', watchOther, false); scope.$watch('size', watchOther, false); scope.$watch('duration', watchOther, false); scope.$watch('foregroundColor', watchOther, false); scope.$watch('backgroundColor', watchOther, false); scope.$watch('thresholds', watchOther, false); scope.$watch('fractionSize', watchData, false); scope.$on('$destroy', function () { }); scope.$on('$resize', function () { }); function watchData(nv, ov) { if (!gauge) return; if (!angular.isDefined(nv) || angular.equals(nv, ov)) return; gauge.update(nv, ov); } function watchOther(nv, ov) { if (!angular.isDefined(nv) || angular.equals(nv, ov)) return; gauge.destroy(); gauge.init(); } } }; } // oude /hub/v1/-adressen naar de pagina die ze vervangt function hubRedirect(p) { return {llmtraffic: '/ai-insights/verkeer', aioverview: '/ai-insights', keywordperformance: '/search-insights/gevolgd'}[p.view] || '/search-insights'; } app.config(['$routeProvider', '$locationProvider', 'USER', '$sceProvider', '$translateProvider', function AppConfig($routeProvider, $locationProvider, USER, $sceProvider, $translateProvider) { angular.lowercase = angular.$$lowercase; $translateProvider.useStaticFilesLoader({ prefix: '/translations/', suffix: '.json?r=' + Math.random() }); /* ZET */ $translateProvider.preferredLanguage(USER.site.locale.lang); $translateProvider.useSanitizeValueStrategy('sce'); $sceProvider.enabled(false); $routeProvider .when('/', { title: 'APP', templateUrl: viewfolder + '/home/index.html?r=' + r, nav: 'dashboard' }) /* help*/ .when('/help', { title: 'Help', templateUrl: viewfolder + '/help/index.html?r=' + r, nav: 'help' }) /* uptime-monitor */ .when('/uptime', { title: 'Uptime', templateUrl: viewfolder + '/uptime/index.html?r=' + r, nav: 'uptime' }) /* meldingen: /meldingen, /meldingen/kanalen, /meldingen/wie, /meldingen/geschiedenis (js/controller/alerts.js) */ .when('/meldingen', { title: 'Meldingen', templateUrl: viewfolder + '/alerts/index.html?r=' + r, nav: 'alerts' }) .when('/meldingen/:tab', { title: 'Meldingen', templateUrl: viewfolder + '/alerts/index.html?r=' + r, nav: 'alerts' }) /* Posities in Google (SERP-tracker): overzicht, /serp/keywords en /serp/competitors; zichtbaar via user.site.show.serp */ .when('/serp', { title: 'Rank tracker', templateUrl: viewfolder + '/serp/index.html?r=' + r, nav: 'serp' }) /* Search Console Insights: overzicht, /onderwerpen(/:id), /gevolgd, /verkennen; zichtbaar via user.site.show.gscinsights (js/controller/gscinsights.js) */ .when('/search-insights', { title: 'Search Insights', templateUrl: viewfolder + '/gscinsights/index.html?r=' + r, nav: 'gscinsights' }) .when('/search-insights/:tab', { title: 'Search Insights', templateUrl: viewfolder + '/gscinsights/index.html?r=' + r, nav: 'gscinsights', reloadOnSearch: false }) .when('/search-insights/:tab/:id', { title: 'Search Insights', templateUrl: viewfolder + '/gscinsights/index.html?r=' + r, nav: 'gscinsights' }) .when('/ai-insights', { title: 'AI Insights', templateUrl: viewfolder + '/aiinsights/index.html?r=' + r, nav: 'aiinsights' }) .when('/ai-insights/:tab', { title: 'AI Insights', templateUrl: viewfolder + '/aiinsights/index.html?r=' + r, nav: 'aiinsights' }) .when('/ai-insights/:tab/:id', { title: 'AI Insights', templateUrl: viewfolder + '/aiinsights/index.html?r=' + r, nav: 'aiinsights' }) .when('/serp/:tab', { title: 'Rank tracker', templateUrl: viewfolder + '/serp/index.html?r=' + r, nav: 'serp' }) /* Core Web Vitals (CrUX): /crux, /crux/bezoekers, /crux/diagnose; zichtbaar via user.site.show.crux (gelanceerd 2026-10-29, standaard aan). De oude tabs van v1 (In Google, Waarom trager) gaan naar hun opvolger; vóór /crux/:tab, de eerste match telt */ .when('/crux/zoekresultaten', { redirectTo: '/crux/bezoekers' }) .when('/crux/waarom', { redirectTo: '/crux/diagnose' }) .when('/crux', { title: 'Core Web Vitals', templateUrl: viewfolder + '/crux/index.html?r=' + r, nav: 'crux' }) .when('/crux/:tab', { title: 'Core Web Vitals', templateUrl: viewfolder + '/crux/index.html?r=' + r, nav: 'crux' }) /* contentcheck (verstopt, alleen eigen admins: contentcheck::allowed) */ .when('/content-intelligence/check', { title: 'Contentcheck', templateUrl: viewfolder + '/content/check.html?r=' + r, nav: 'content' }) .when('/content-intelligence/check/:id', { title: 'Contentcheck', templateUrl: viewfolder + '/content/check.html?r=' + r, nav: 'content' }) /* mcp server */ .when('/mcp', { title: 'MCP Server', templateUrl: viewfolder + '/mcp/index.html?r=' + r, nav: 'mcp' }) /* onpage seo*/ .when('/onpage-seo/alerts', { redirectTo: '/meldingen' }) .when('/onpage-seo', { title: 'ONPAGE', templateUrl: viewfolder + '/onpage/index.status.html?r=' + r, nav: 'onpage' }) .when('/onpage-seo/scan/:pageid', { title: 'ONPAGE SCAN', templateUrl: viewfolder + '/onpage/scan.html?r=' + r, nav: 'onpage', controller: function ($scope, $routeParams) { $scope.pageid = $routeParams.pageid; }, }) .when('/onpage-seo/:viewrulelabel', { title: 'ONPAGE', templateUrl: viewfolder + '/onpage/index.status.html?r=' + r, nav: 'onpage', controller: function ($scope, $routeParams) { $scope.viewrulelabel = $routeParams.viewrulelabel; }, }) .when('/onpage-seo/:viewrulelabel/:viewrule', { title: 'ONPAGE', templateUrl: viewfolder + '/onpage/index.status.html?r=' + r, nav: 'onpage', controller: function ($scope, $routeParams) { $scope.viewrulelabel = $routeParams.viewrulelabel; $scope.viewrule = $routeParams.viewrule; }, }) .when('/content/', { title: 'Content Intelligence', templateUrl: viewfolder + '/content/index.html?r=' + r, nav: 'content' }) .when('/content/:modelid', { title: 'Content Intelligence', templateUrl: viewfolder + '/content/model.html?r=' + r, nav: 'content', controller: function ($scope, $routeParams) { $scope.modelid = $routeParams.modelid; }, }) /* linkbuilding v2: Overzicht, Pijplijn, Outreach, Bewaakte links (js/controller/linkbuilding.js); de oude pagina's verwijzen door */ .when('/linkbuilding/assistant', { redirectTo: '/linkbuilding/pijplijn' }) .when('/linkbuilding/template', { redirectTo: '/linkbuilding/outreach' }) .when('/linkbuilding/wizzard', { redirectTo: '/linkbuilding' }) .when('/linkbuilding/manager', { redirectTo: '/linkbuilding' }) .when('/linkbuilding', { title: 'Linkbuilding', templateUrl: viewfolder + '/linkbuilding/index.html?r=' + r, nav: 'linkbuilding' }) .when('/linkbuilding/:tab', { title: 'Linkbuilding', templateUrl: viewfolder + '/linkbuilding/index.html?r=' + r, nav: 'linkbuilding' }) .when('/llm-visibility', { redirectTo: '/ai-insights/antwoorden' }) /* reports*/ .when('/analytics/:report/:view', { title: 'SITES', templateUrl: viewfolder + '/api/analytics.html?r=' + r, nav: 'analytics', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) /* Analytics (GA4) voor iedereen (Arjen 2026-11-03, was proef v5): /ga4//. Filters staan in de querystring: geen herladen bij een andere filter. Koppelen en de property kiezen gebeuren daar ook; de originele pagina's zijn weg (2026-11-03). */ .when('/ga4', { title: 'Analytics', templateUrl: viewfolder + '/ga4/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/ga4/:report', { title: 'Analytics', templateUrl: viewfolder + '/ga4/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/ga4/:report/:view', { title: 'Analytics', templateUrl: viewfolder + '/ga4/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) /* oude adressen van de proefversies naar Analytics */ .when('/analytics4', { redirectTo: '/ga4' }) .when('/analytics4/:tab', { redirectTo: '/ga4' }) .when('/analytics3', { redirectTo: '/ga4' }) .when('/analytics3/:tab', { redirectTo: '/ga4' }) .when('/analytics-v4', { redirectTo: '/ga4' }) .when('/analytics-v4/:tab', { redirectTo: '/ga4' }) .when('/analytics-v5', { redirectTo: '/ga4' }) .when('/analytics-v5/:report', { redirectTo: '/ga4/:report' }) .when('/analytics-v5/:report/:view', { redirectTo: '/ga4/:report/:view' }) /* PROEF Analytics v1-v4, alleen Arjen (user.owner). Weggooien: docs/proef/README.md */ .when('/proef/analytics-v1', { title: 'Analytics', templateUrl: viewfolder + '/proef/v1/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v1/:tab', { title: 'Analytics', templateUrl: viewfolder + '/proef/v1/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v2', { title: 'Analytics', templateUrl: viewfolder + '/proef/v2/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v2/:tab', { title: 'Analytics', templateUrl: viewfolder + '/proef/v2/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v3', { title: 'Analytics', templateUrl: viewfolder + '/proef/v3/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v3/:tab', { title: 'Analytics', templateUrl: viewfolder + '/proef/v3/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v4', { title: 'Analytics', templateUrl: viewfolder + '/proef/v4/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) .when('/proef/analytics-v4/:tab', { title: 'Analytics', templateUrl: viewfolder + '/proef/v4/index.html?r=' + r, nav: 'analytics4', reloadOnSearch: false }) /* einde PROEF */ /* de originele GA4-pagina's (weg sinds 2026-11-03, Arjen): naar hetzelfde rapport op /ga4 */ .when('/analytics4/:report/:view', { redirectTo: function (p) { var report = { channels: 'kanalen', audience: 'publiek', pages: 'paginas', events: 'gebeurtenissen', conversions: 'conversies' }[p.report] || 'kanalen'; var view = report === 'kanalen' ? ({ organic: 'organic', paid: 'paid', direct: 'direct', social: 'social', referral: 'referral', email: 'email' }[p.view] || 'alle') : ''; return '/ga4/' + report + (view ? '/' + view : ''); } }) .when('/matomo/:report/:view', { title: 'SITES', templateUrl: viewfolder + '/api/matomo.html?r=' + r, nav: 'matomo', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) /* de oude Search Console-pagina is Search Insights geworden (2026-10-27) */ .when('/searchconsole/:report/:view', { redirectTo: '/search-insights' }) .when('/searchconsole/:report/:view/:urlfilter', { redirectTo: '/search-insights' }) .when('/youtube/:report/:view', { title: 'youtube', templateUrl: viewfolder + '/api/youtube.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) // de Performance HUB is opgegaan in Search Insights en AI Insights (2026-10-27); een functie, anders zet // Angular de routeparameters als ?report=...&view=... achter het nieuwe adres .when('/hub/:report/:view', { redirectTo: hubRedirect }) .when('/hub/:report/:view/:urlfilter', { redirectTo: hubRedirect }) .when('/mailchimp/:report/:view', { title: 'mailchimp', templateUrl: viewfolder + '/api/mailchimp.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/mailchimp/:report/:view/:filter', { title: 'mailchimp', templateUrl: viewfolder + '/api/mailchimp.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; $scope.urlfilter = $routeParams.filter; }, }) .when('/facebookads/:report/:view', { title: 'facebookads', templateUrl: viewfolder + '/api/facebookxads.html?r=' + r, nav: 'ads', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/facebookinsights/:report/:view', { title: 'facebookinsights', templateUrl: viewfolder + '/api/facebookinsights.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/instagram/:report/:view', { title: 'instagram', templateUrl: viewfolder + '/api/instagram.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/linkedin/:report/:view', { title: 'linkedin', templateUrl: viewfolder + '/api/linkedin.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/linkedinxads/:report/:view', { title: 'linkedinxads', templateUrl: viewfolder + '/api/linkedinxads.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/googleads/:report/:view', { title: 'googleads', templateUrl: viewfolder + '/api/googlexads.html?r=' + r, nav: 'ads', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/mybusiness/:report/:view', { title: 'MYBUSINESS', templateUrl: viewfolder + '/api/mybusiness.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; $scope.urlview = $routeParams.view; }, }) .when('/planning', { title: 'planning', templateUrl: viewfolder + '/planning/index.html?r=' + r, nav: 'planning', }) .when('/site', { title: 'SITES', templateUrl: viewfolder + '/site/index.html?r=' + r, nav: 'sites', }) .when('/site/upgrade/:orderstate', { title: 'SITES', templateUrl: viewfolder + '/site/upgrade.html?r=' + r, nav: 'sites', controller: function ($scope, $routeParams) { $scope.orderstate = $routeParams.orderstate; }, allowguest: true }) .when('/site/upgrade', { title: 'SITES', templateUrl: viewfolder + '/site/upgrade.html?r=' + r, nav: 'sites', allowguest: true }) .when('/sites', { title: 'SITES', templateUrl: viewfolder + '/sites/index.html?r=' + r, nav: 'sites', }) .when('/sites/new', { title: 'Add a new site', templateUrl: viewfolder + '/sites/first.html?r=' + r, nav: 'sites', }) .when('/rank-tracker', { title: 'Rank Tracker', templateUrl: viewfolder + '/rank-tracker/index.html?r=' + r, nav: 'ranktracker', }) .when('/rank-tracker/competitors', { title: 'Rank Tracker - Competitors', templateUrl: viewfolder + '/rank-tracker/competitors.html?r=' + r, nav: 'ranktracker', }) .when('/rank-tracker/insights', { title: 'Rank Tracker - Insights', templateUrl: viewfolder + '/rank-tracker/insights.html?r=' + r, nav: 'ranktracker', }) .when('/keywordtool', { title: 'Rank Tracker', templateUrl: viewfolder + '/keywordtool/index.html?r=' + r, nav: 'keywordtool', }) .when('/insights', { title: 'Insights', templateUrl: viewfolder + '/insights/index.html?r=' + r, nav: 'insights', }) .when('/insights/:report', { title: 'Insights', templateUrl: viewfolder + '/insights/index.html?r=' + r, nav: 'tools', controller: function ($scope, $routeParams) { $scope.urlreport = $routeParams.report; }, }) /*report*/ .when('/report', { title: 'Report', templateUrl: viewfolder + '/report/index.html?r=' + r, nav: 'report', }) .when('/report/:report_id', { title: 'Report', templateUrl: viewfolder + '/report/report.html?r=' + r, nav: 'report', controller: function ($scope, $routeParams) { $scope.report_id = $routeParams.report_id; }, }) .when('/reportpage/:key', { title: 'Report', templateUrl: viewfolder + '/report/page.html?r=' + r, nav: 'report', controller: function ($scope, $routeParams) { $scope.key = $routeParams.key; }, }) /* guest*/ .when('/sites/first', { title: 'Add your first site', templateUrl: viewfolder + '/sites/first.html?r=' + r, nav: 'sites', allowguest: true, }) .when('/user/login', { title: 'LOGIN', allowguest: true, nav: 'user', class: 'fullscreen', templateUrl: viewfolder + '/user/login.html?r=' + r, }) .when('/user', { title: 'USER', nav: 'user', templateUrl: viewfolder + '/user/index.html?r=' + r, }) .when('/user/register', { title: 'LOGIN', nav: 'user', class: 'fullscreen', templateUrl: viewfolder + '/user/register.html?r=' + r, allowguest: true, }) .when('/user/recover', { title: 'RECOVER PASSWORD', templateUrl: viewfolder + '/user/recover.html?r=' + r, nav: 'user', class: 'fullscreen', allowguest: true, }) .when('/settings/reseller', { title: 'RESELLER', templateUrl: viewfolder + '/reseller/index.html?r=' + r, nav: 'user', allowguest: true, }) .when('/settings/reseller/:tab', { title: 'RESELLER', templateUrl: viewfolder + '/reseller/index.html?r=' + r, nav: 'user', allowguest: true, }) .when('/settings/site', { title: 'RESELLER', templateUrl: viewfolder + '/site/index.html?r=' + r, nav: 'user', allowguest: false, }) .otherwise({ redirectTo: '/' }); // enable html5Mode for pushstate ('#'-less URLs) $locationProvider.html5Mode(true); }]); app.run(['$route', '$rootScope', '$location', '$window', 'USER', function ($route, $rootScope, $location, $window, USER) { $rootScope.r = r; $rootScope.user = USER; //set menu state $rootScope.app = { body: { class: ['md-open', 'closed'] } }; $rootScope.$on('$routeChangeSuccess', function (event, current, prev) { $rootScope.app = { body: { class: ['md-open', 'closed'] } }; $rootScope.modal = {}; if (!$rootScope.user._id && !$route.current.allowguest) { // na het inloggen terug naar deze pagina (met ?site=, zie core.js) try { window.sessionStorage.setItem('mt.afterLogin', $location.url()); } catch (e) { /* geen opslag: dan de start */ } $location.url('/user/login'); } else if (!$rootScope.user.site._id && !$route.current.allowguest) { $location.path('/sites/first'); } else if (!$route.current.allowguest && $rootScope.user.site._id && current.originalPath != '/site/upgrade' && $rootScope.user.site.status.expire - (Date.now() / 1000) < 0) { //if ($rootScope.user.reseller.config.own) { $location.path('/site/upgrade'); //} } // het menu (2026-10-29): de AI-crawlers staan onder AI Insights en de snelheid per pagina onder Snelheid, als die er zijn var nav = $route.current.nav, label = ($route.current.params || {}).viewrulelabel, u = $rootScope.user || USER, show = (u.site && u.site.show) || {}; if (nav === 'onpage' && label === 'aibots' && show.aiinsights) { nav = 'aiinsights'; } if (nav === 'onpage' && label === 'speed' && show.crux) { nav = 'crux'; } $rootScope.app.meta = { nav: nav, class: $route.current.class, title: USER.reseller.config.name + ' - ' + $route.current.title, description: $route.current.description }; $rootScope.app.sitesdropdown = false; }); }]) app.controller('core', ['$scope', '$rootScope', '$location', 'h', '$templateRequest', '$window', '$http', '$route', '$translate', '$http', function($scope, $rootScope, $location, h, $templateRequest, $window, $http, $route, $translate, $http) { $scope.fnToggleMenu = function() { $rootScope.app.body.class = ($rootScope.app.body.class[0] == 'md-open') ? ['md-closed', 'open'] : ['md-open', 'closed']; } $scope.fnLogout = function() { $http.post('/rest/user/logout').then(function successCallback(res) { h.login(function() { $location.path('/user/login'); }); }); } $scope.fnModal = function(url) { $rootScope.modal = {}; $rootScope.modal.show = true; $rootScope.modal.url = viewfolder+'/' + url + '.html'; } $scope.fnCloseModal = function() { $rootScope.modal = {}; } $scope.fnClick = function(path) { $location.path(path); } $scope.fnGoto = function(path) { //$location.path(path); $window.location.href = path; } $scope.fnSetLang = function(lang) { $rootScope.user.site.locale.lang = lang; $translate.use(lang); $http.get('/rest/global/lang.set', { params: { 'lang': lang, } }); } // links naar de app (mail, Slack, zelf gedeeld) openen de juiste site: /?site=, daarna weg uit het // adres. Niet ingelogd: app.js bewaart het adres en het inloggen gaat er daarna heen. Geen toegang tot die site: // een melding bovenaan en je blijft op de huidige site. function openLinkSite() { var linkSite = $location.search().site; if (!linkSite || !$rootScope.user._id) { return; } $location.search('site', null).replace(); if (/^[a-f0-9]{24}$/.test(linkSite) && linkSite !== h.siteId()) { $http.post('/rest/sites/change', { websiteid: linkSite }).then(function() { $rootScope.linkSiteDenied = false; h.login(function() { $rootScope._pages = false; $rootScope._history = false; $route.reload(); }); }, function() { $rootScope.linkSiteDenied = true; }); } } openLinkSite(); $scope.$on('$routeChangeSuccess', openLinkSite); $scope.fnChangeSite = function(site) { $http.post('/rest/sites/change', { websiteid: site._id.$oid }).then(function successCallback(res) { h.login(function() { $rootScope._pages = false; $rootScope._history = false; if ($location.path() == '/site/upgrade') { $scope.fnClick('/'); } $route.reload(); }); }); } // de lijst pas opbouwen bij de eerste klik; openen en sluiten doet Bootstrap zelf // (omdraaien liep uit de pas met Bootstrap: na sluiten zonder keuze bleef de lijst leeg) $scope.fnSitesDropdown = function(site) { $rootScope.app.sitesdropdown = true; } $scope.fnDaysLeft = function() { return Math.ceil(($rootScope.user.site.status.expire - (Date.now() / 1000)) / (24 * 60 * 60)); } $scope.isValidSite = function() { return ($rootScope.user.site.status.expire > (Date.now() / 1000)) ? true : false; } // heeft de white label van dit domein een lopend abonnement (betaald en de 40 dagen nog niet om)? $scope.resellerPaid = function() { var m = (($rootScope.user || {}).reseller || {}).mandate || {}; return m.status === 'done' && +(m.expire || 0) > (Date.now() / 1000); } $scope.colors = ['#E91E63', '#3F51B5', '#00BCD4', '#8BC34A', '#FF9800', '#795548', '#607D8B', '#00CC00', '#0000CC', '#DDDDDD', '#999999', '#333333', '#990000'] $scope.fnPdf = function() { console.log('asdasdasd'); var imgs = document.getElementsByTagName('canvas'); for (var i in imgs) { if (typeof(imgs[i]) == 'object') { var img = imgs[i].toDataURL("image/png"); var replace = document.createElement("img"); replace.src = img; imgs[i].parentNode.replaceChild(replace, imgs[i]); } } $http.post("/rest/report/page.download", { html: angular.element('.reportcontainer').html() }).then(function successCallback(res) { $scope.fnGoto('https://robinssl.seoserver.nl/engels/page.php?website_id=' + h.siteId() + '&reseller_id=' + $scope.user.reseller._id.$oid + '&key=' + res.data.k) /*$http.get('https://robinssl.seoserver.nl/engels/page.php', { 'params': { 'website_id': h.siteId(), 'reseller_id': $scope.user.reseller._id.$oid, 'key': res.data.k } }).then(function successCallback(pdf) { var newBlob = new Blob([pdf.data], { type: "application/pdf" }) console.log(newBlob); });*/ }); } } ]); // Dashboard (/): widgets per site in de stijl van de nieuwe pagina's (docs/ui.md). Iedereen deelt per site dezelfde // indeling; rol 4 of lager mag hem aanpassen: slepen, breedte trekken aan de rechterrand, instellingen, weghalen, en // widgets of een hele indeling kiezen in de widgetkiezer. Opslag en regels: rest/include/home.php en rest/data/home/. // Arjen (2026-10-28): wie al een dashboard had, ziet dezelfde widgets op dezelfde plek; alleen nieuwe sites krijgen de // nieuwe standaardindeling. Search Insights en AI Insights tekenen hun tegels en grafieken met dezelfde code als hun // eigen pagina (gsciView, aiView). app.controller('home', ['$scope', '$rootScope', '$http', '$translate', '$timeout', '$q', 'h', 'mtFmt', 'gsciView', 'aiView', function($scope, $rootScope, $http, $translate, $timeout, $q, h, mtFmt, gsciView, aiView) { if ($rootScope.user.firstrun) { $scope.fnModal('home/modal.firstrun'); } $scope.fnCloseFirstRun = function() { $http.get('/rest/user/firstrun.set'); $rootScope.user.firstrun = false; $scope.fnCloseModal(); } // favoriet: de huidige site in user.sites (favorieten staan bovenaan in de siteswitcher) var fnCurrentSite = function() { return ($rootScope.user.sites || []).find(function(site) { return site._id && site._id.$oid == h.siteId(); }); } $scope.isFavorite = function() { var site = fnCurrentSite(); return !!(site && site.favorite); } $scope.fnFavorite = function() { var site = fnCurrentSite(); if (!site) { return; } site.favorite = !site.favorite; $http.post('/rest/sites/favorite', { websiteid: h.siteId(), favorite: site.favorite }).then(function successCallback(res) { if (res.data.cb != 'ok') { site.favorite = !site.favorite; } }, function errorCallback() { site.favorite = !site.favorite; }); } var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; var show = ($rootScope.user.site && $rootScope.user.site.show) || {}; var WIDTHS = [3, 4, 6, 8, 9, 12]; var MAX = 24; var GA4_METRICS = ['sessions', 'totalUsers', 'screenPageViews', 'screenPageViewsPerSession', 'averageSessionDuration', 'newUsers', 'bounceRate', 'eventCount', 'eventCountPerUser']; var ADS_METRICS = ['Clicks', 'Impressions', 'Cost', 'AverageCpc', 'Ctr', 'Conversions', 'CostPerConversion', 'ConversionRate', 'ConversionValue', 'SearchImpressionShare', 'SearchAbsoluteTopImpressionShare', 'roas']; var GSC_METRICS = ['clicks', 'impressions', 'ctr', 'position']; // Analytics 4 per kanaal (sessionDefaultChannelGroup; analytics.view filters.channel) var GA4_CHANNELS = { organic: 'Organic Search', paid: 'Paid Search', direct: 'Direct', referral: 'Referral', social: 'Organic Social', email: 'Email' }; // gemiddelden tel je niet op: in de kop het gemiddelde in plaats van het totaal var AVERAGES = ['screenPageViewsPerSession', 'averageSessionDuration', 'bounceRate', 'eventCountPerUser', 'AverageCpc', 'Ctr', 'CostPerConversion', 'ConversionRate', 'SearchImpressionShare', 'SearchAbsoluteTopImpressionShare', 'roas', 'ctr', 'position']; var ICONS = { all: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', gsc: 'M21 21l-4.3-4.3M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14z', ai: 'M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z', seo: 'M9 11l3 3 8-8M20 12v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11', rank: 'M4 20V10M10 20V4M16 20v-6M22 20H2', up: 'M3 12h4l3-8 4 16 3-8h4', ga: 'M4 20v-6M10 20V8M16 20v-9M22 20V4', ads: 'M3 11l18-8-8 18-2-8z', star: 'M12 3l2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2l1.1-6.2L3 9.6l6.2-.9z', layout: 'M3 4h18v6H3zM3 14h8v6H3zM15 14h6v6h-6z' }; // de groepen in de widgetkiezer, met de tool die in het menu moet staan var CATS = [ { key: 'all_tools', icon: ICONS.all, tool: null }, { key: 'gscinsights', icon: ICONS.gsc, tool: 'gscinsights', href: '/search-insights' }, { key: 'aiinsights', icon: ICONS.ai, tool: 'aiinsights', href: '/ai-insights' }, { key: 'onpage', icon: ICONS.seo, tool: 'onpageseo', href: '/onpage-seo' }, { key: 'serp', icon: ICONS.rank, tool: 'serp', href: '/serp' }, { key: 'uptime', icon: ICONS.up, tool: 'uptime', href: '/uptime' }, { key: 'analytics', icon: ICONS.ga, tool: 'analytics', href: '/ga4/kanalen' }, { key: 'googleads', icon: ICONS.ads, tool: 'googleads', href: '/googleads/campaigns/all' } ]; var CAT = {}; CATS.forEach(function (c) { CAT[c.key] = c; }); // alle widgets (zelfde lijst als homeWidgets::TYPES): groep, voorbeeldplaatje, standaardbreedte, instellingen var WIDGETS = { // De stand in één zin rekent met Search Insights: zonder die tool niet te kiezen summary: { cat: 'all_tools', kind: 'sentence', span: 12, tip: true, needs: 'gscinsights' }, todo: { cat: 'all_tools', kind: 'list', span: 8, tip: true }, si_kpis: { cat: 'gscinsights', kind: 'kpis', span: 12, tip: true }, si_chart: { cat: 'gscinsights', kind: 'bars', span: 8, metrics: GSC_METRICS, metric: 'clicks', tip: true }, si_changes: { cat: 'gscinsights', kind: 'list', span: 4 }, si_kansen: { cat: 'gscinsights', kind: 'list', span: 6 }, searchinsights_tracked: { cat: 'gscinsights', kind: 'table', span: 6, more: '/search-insights/gevolgd' }, searchconsole_timeline: { cat: 'gscinsights', kind: 'line', span: 6, metrics: GSC_METRICS, metric: 'clicks', link: function () { return explore({ dim: 'date' }); } }, searchconsole_chart: { cat: 'gscinsights', kind: 'top', span: 6, metrics: GSC_METRICS, metric: 'clicks', dimensions: ['query', 'page', 'country', 'device'], dimension: 'query', link: function (w) { return explore({ dim: w.dimension || 'query', sort: w.metric || 'clicks' }); } }, searchconsole_keywords: { cat: 'gscinsights', kind: 'table', span: 6, more: '/search-insights/verkennen' }, ai_kpis: { cat: 'aiinsights', kind: 'kpis', span: 12 }, ai_chart: { cat: 'aiinsights', kind: 'bars', span: 6, more: '/ai-insights/verkeer' }, onpage_seo: { cat: 'onpage', kind: 'kpis', span: 12 }, onpage_changes: { cat: 'onpage', kind: 'list', span: 6, more: '/onpage-seo/changes' }, onpage_changes_donut: { cat: 'onpage', kind: 'counts', span: 4, more: '/onpage-seo/changes' }, ranktracer: { cat: 'serp', kind: 'dist', span: 6, more: '/serp/keywords' }, uptime: { cat: 'uptime', kind: 'heat', span: 4 }, analytics4_timeline: { cat: 'analytics', kind: 'line', span: 4, metrics: GA4_METRICS, metric: 'sessions', filters: Object.keys(GA4_CHANNELS), link: function (w) { return '/ga4/kanalen/' + (GA4_VIEW[w.filter] || 'alle'); } }, googleads_timeline: { cat: 'googleads', kind: 'line', span: 6, metrics: ADS_METRICS, metric: 'Clicks', link: function (w) { return '/googleads/campaigns/all?' + qs({ metric: w.metric }); } }, googleads_performance_chart: { cat: 'googleads', kind: 'top', span: 6, metrics: ADS_METRICS, metric: 'Clicks', dimensions: ['CampaignName', 'AdGroupName', 'Query'], dimension: 'CampaignName', link: function (w) { return '/googleads/campaigns/' + (w.dimension === 'AdGroupName' ? 'adgroups' : 'all') + '?' + qs({ metric: w.metric }); } }, googleads_conversion_chart: { cat: 'googleads', kind: 'top', span: 6, metrics: ['Conversions', 'CostPerConversion', 'ConversionRate', 'ConversionValue'], metric: 'Conversions', dimensions: ['ConversionTypeName'], dimension: 'ConversionTypeName', link: function (w) { return '/googleads/campaigns/all?' + qs({ metric: w.metric }); } } }; var offered = function (type) { var w = WIDGETS[type], tool = w && (w.needs || CAT[w.cat].tool); return !!w && (!tool || !!show[tool]); }; var d = $scope.d = { f: f, icons: ICONS, widgets: [], loaded: false, edit: false, compare: 'prev', hasCompare: false, canEdit: ($rootScope.user.site && $rootScope.user.site.role <= 4), max: MAX, picker: null, templates: [], canTemplates: false, confirm: null, form: null, drag: null, over: null, settings: null }; // ------------------------------------------------------------------ gegevens // elk adres één keer per bezoek: widgets die hetzelfde nodig hebben (De stand, Doe dit eerst, de tegels) delen het var cache = {}; var get = function (url, params) { var p = angular.extend({ websiteid: h.siteId() }, params || {}); var key = url + '|' + JSON.stringify(p); if (!cache[key]) { cache[key] = $http.get(url, { params: p }).then(function (res) { return res.data; }, function (res) { return { error: res.status === 403 ? 'no access' : 'failed' }; }); } return cache[key]; }; // API v1 (/rest/v1/, {data, meta}), ook één keer per bezoek // de uptime-widgets en "Doe dit eerst" lezen de vorm van de pagina (uptime.view); fouten zoals get() var uptimeView = function () { return v1('uptime.view').then(function (x) { return x.error ? x : x.data; }); }; var v1 = function (name, body) { var b = angular.extend({ site_id: h.siteId() }, body || {}); var key = 'v1|' + name + '|' + JSON.stringify(b); if (!cache[key]) { cache[key] = $http.post('/rest/v1/' + name, b).then(function (res) { return res.data; }, function (res) { return { error: res.status === 403 ? 'no access' : 'failed' }; }); } return cache[key]; }; var state = function (kind, extra) { return angular.extend({ state: kind }, extra || {}); }; var connect = function (cat) { return state('connect', { href: CAT[cat].href }); }; // Search Console: niet gekoppeld of geen property gekozen = koppelen; anders een fout. // gscinsights/report zonder koppeling: action (getcustomer, of connect met voorbeelddata die we niet tonen) var gscState = function (x) { if (x && x.action) { return connect('gscinsights'); } if (!x || !x.error) { return null; } return x.error === 'not_connected' || x.error === 'no_property' ? connect('gscinsights') : state('error'); }; // pad zonder domein; de scan bewaart adressen met en zonder https:// var path = function (u) { return String(u || '').replace(/^(https?:\/\/)?[^\/]+/, '') || '/'; }; var countryName = function (cc) { try { return cc ? new Intl.DisplayNames([$translate.use() || 'nl'], { type: 'region' }).of(cc.toUpperCase()) : ''; } catch (e) { return cc; } }; var metricFmt = function (m) { if (m === 'ctr' || m === 'bounceRate' || m === 'Ctr' || m === 'ConversionRate' || m === 'SearchImpressionShare' || m === 'SearchAbsoluteTopImpressionShare') { return function (v) { return f.pct(v); }; } if (m === 'position') { return f.pos; } if (m === 'Cost' || m === 'AverageCpc' || m === 'CostPerConversion' || m === 'ConversionValue') { return f.euro; } if (m === 'screenPageViewsPerSession' || m === 'eventCountPerUser' || m === 'roas') { return function (v) { return f.num(v, 2); }; } return function (v) { return f.num(v); }; }; // een reeks per dag (GA4, Google Ads, Search Console) als lijn, met het totaal of gemiddelde in de kop. Vandaag // telt nog maar half (Arjen 2026-10-28: de lijn dook aan het eind omlaag) en valt weg. // fmtMetric: de metriek voor opmaak en gemiddelde, als de waarde onder een andere sleutel staat (GA4: value) var dayLine = function (rows, dateKey, metric, label, fmtMetric) { var iso = function (r) { var s = String(r[dateKey]); return s.length === 8 ? s.substr(0, 4) + '-' + s.substr(4, 2) + '-' + s.substr(6, 2) : s.substr(0, 10); }; var n = new Date(), today = n.getFullYear() + '-' + ('0' + (n.getMonth() + 1)).slice(-2) + '-' + ('0' + n.getDate()).slice(-2); rows = (rows || []).filter(function (r) { return iso(r) < today; }).sort(function (a, b) { return iso(a) < iso(b) ? -1 : 1; }); var dates = rows.map(iso); var values = rows.map(function (r) { var v = parseFloat(r[metric]); return isNaN(v) ? null : v; }); var sum = values.reduce(function (a, v) { return a + (v || 0); }, 0); var fm = fmtMetric || metric, fmt = metricFmt(fm); return { kind: 'line', fmt: fmt, invert: fm === 'position', big: values.length ? fmt(AVERAGES.indexOf(fm) >= 0 ? sum / values.length : sum) : '–', bigLabel: AVERAGES.indexOf(fm) >= 0 ? t('home.w.average') : t('home.w.total'), chart: { labels: dates.map(function (x, i) { return i % 7 === 0 ? f.dayWeek(x) : ''; }), titles: dates.map(function (x) { return f.dayWeek(x); }), series: [{ label: label, color: '#2563eb', values: values, width: 2.5, strong: true }] } }; }; // de grootste 8 per dimensie, met een balk naar verhouding var topRows = function (rows, dim, metric, href, label) { var fmt = metricFmt(metric); rows = (rows || []).slice().sort(function (a, b) { return (parseFloat(b[metric]) || 0) - (parseFloat(a[metric]) || 0); }).slice(0, 8); var max = rows.length ? Math.max.apply(null, rows.map(function (r) { return parseFloat(r[metric]) || 0; })) : 0; return { kind: 'top', rows: rows.map(function (r) { var v = parseFloat(r[metric]) || 0; return { label: label(r), href: href ? href(r) : null, value: fmt(v), share: max ? 100 * v / max : 0 }; }) }; }; // deep links (Arjen 2026-10-28): elke tegel, rij en "Bekijken" opent de plek op de pagina van de tool waar het // over gaat, met dezelfde metriek en hetzelfde filter. De pagina's lezen dit alleen bij het openen. var qs = function (o) { return Object.keys(o).filter(function (k) { return o[k] !== null && o[k] !== undefined && o[k] !== ''; }).map(function (k) { return k + '=' + encodeURIComponent(o[k]); }).join('&'); }; var explore = function (o) { return '/search-insights/verkennen?' + qs(o); }; var GA4_VIEW = { organic: 'organic', paid: 'paid', direct: 'direct', referral: 'referral', social: 'social', email: 'email' }; var ONPAGE_VIEW = { all: 'all', content: 'content', technical: 'technical', mobile: 'mobile', indexability: 'indexability' }; var exploreRow = function (dim, r) { if (dim === 'page') { return explore({ page: r.page, page_op: 'equals' }); } if (dim === 'country') { return explore({ country: r.country }); } if (dim === 'device') { return explore({ device: String(r.device || '').toLowerCase() }); } return gsciView.exploreHref(r.query); }; var LOAD = { si_kpis: function (w) { return get('/rest/gscinsights/overview', { part: 'kpis', compare: d.compare }).then(function (x) { if (gscState(x)) { return gscState(x); } // klikken, vertoningen, CTR, positie: Verkennen gesorteerd op die kolom; conversies of omzet: per pagina return { kind: 'kpis', kpis: gsciView.kpis(x.totals.now, x.totals.before, x.ga4).map(function (k) { k.href = !k.key ? null : (k.key === 'conversions' || k.key === 'revenue' ? explore({ dim: 'page', sort: k.key }) : explore({ sort: k.key })); return k; }) }; }); }, si_chart: function (w) { return get('/rest/gscinsights/overview', { part: 'chart', compare: 'prev' }).then(function (x) { if (gscState(x)) { return gscState(x); } var c = gsciView.overviewChart(x, w.metric || 'clicks'); return { kind: c.chartKind === 'line' ? 'line' : 'bars', chart: c.chart, fmt: c.chartFmt, invert: c.chartInvert, title: c.chartTitle, sub: c.brandNote }; }); }, si_changes: function (w) { return get('/rest/gscinsights/overview', { part: 'why', compare: d.compare }).then(function (x) { if (gscState(x)) { return gscState(x); } var why = gsciView.why(x.why); if (!why.rows.length) { return state('empty', { text: t('home.w.no_changes') }); } return { kind: 'list', sub: why.text, rows: why.rows.slice(0, 6).map(function (r) { return { lead: (r.up ? '+' : '') + r.diff, leadCls: r.up ? 'text-green-600' : 'text-red-600', text: r.q, sub: r.why, href: r.href }; }) }; }); }, si_kansen: function (w) { return get('/rest/gscinsights/overview', { part: 'actions', compare: 'prev' }).then(function (x) { if (gscState(x)) { return gscState(x); } if (!x.actions.length) { return state('empty', { text: t('home.w.no_chances') }); } return { kind: 'list', rows: x.actions.slice(0, 6).map(function (a) { return { badge: a.kind, text: gsciView.actionText(a), end: a.gain ? '+' + f.num(a.gain) + ' ' + t('home.w.clicks') : '', href: gsciView.exploreHref(a.query) }; }) }; }); }, searchinsights_tracked: function (w) { return get('/rest/gscinsights/tracked').then(function (x) { if (gscState(x)) { return gscState(x); } var months = (x.months || []).map(function (m) { return f.monthLong(m); }); var rows = []; (x.groups || []).forEach(function (g) { (g.keywords || []).forEach(function (k) { rows.push(k); }); }); if (!rows.length) { return state('empty', { text: t('gscinsights.intro.tracked'), href: '/search-insights/gevolgd', action: t('gscinsights.tracked.add') }); } rows.sort(function (a, b) { return b.now.clicks - a.now.clicks; }); return { kind: 'tracked', months: months, rows: rows.slice(0, 8).map(function (k) { return { q: k.query, href: gsciView.exploreHref(k.query), pos: f.pos(k.now.position), posDelta: k.year && k.year.position && k.now.position ? k.now.position - k.year.position : null, clicks: f.num(k.now.clicks), change: k.change, spark: k.monthly_pos }; }) }; }); }, searchconsole_timeline: function (w) { var m = w.metric || 'clicks'; return get('/rest/gscinsights/report', { dimension: 'date', end: 'TODAY', metrics: m, start: '30DAYSAGO', report: 'searchAnalytics' }).then(function (x) { return gscState(x) || dayLine(x.data, 'date', m, t('gscinsights.kpi.' + m)); }); }, searchconsole_chart: function (w) { var m = w.metric || 'clicks', dim = w.dimension || 'query'; return get('/rest/gscinsights/report', { dimension: dim, end: 'TODAY', metrics: m, start: '30DAYSAGO' }).then(function (x) { return gscState(x) || topRows(x.data, dim, m, function (r) { return exploreRow(dim, r); }, function (r) { if (dim === 'page') { return path(r.page); } if (dim === 'country') { return countryName(r.cc) || String(r.country || '').toUpperCase(); } if (dim === 'device') { return t('gscinsights.explore.device_name.' + r.device); } return r[dim]; }); }); }, searchconsole_keywords: function (w) { return get('/rest/gscinsights/report', { dimension: 'query', end: 'TODAY', metrics: 'clicks,ctr,position,impressions', start: '30DAYSAGO', report: 'searchAnalytics' }).then(function (x) { if (gscState(x)) { return gscState(x); } return { kind: 'keywords', rows: (x.data || []).slice(0, 10).map(function (r) { return { q: r.query, href: gsciView.exploreHref(r.query), pos: f.pos(r.position), clicks: f.num(r.clicks), impressions: f.num(r.impressions), ctr: f.pct(r.ctr) }; }) }; }); }, ai_kpis: function (w) { return $q.all([v1('aiinsights.summary'), v1('aiinsights.traffic_summary')]).then(function (r) { if (r[0].error || r[1].error) { return state('error'); } return { kind: 'kpis', kpis: aiView.kpis(r[0].data, r[1].data).map(function (k, i) { k.href = i < 2 ? '/ai-insights/antwoorden' : '/ai-insights/verkeer'; return k; }) }; }); }, ai_chart: function (w) { return $q.all([v1('aiinsights.traffic_monthly'), v1('aiinsights.summary')]).then(function (r) { if (r[0].error || r[1].error) { return state('error'); } var c = aiView.chart(r[0].data, r[1].data.ga4_connected); return c ? { kind: 'bars', chart: c } : connect('analytics'); }); }, onpage_seo: function (w) { return get('/rest/onpage/list').then(function (x) { var hist = (x && x.history) || []; if (!hist.length) { return state('empty', { text: t('home.w.no_scan') }); } var last = hist[hist.length - 1], prev = hist.length > 1 ? hist[hist.length - 2] : null; var tile = function (key, label) { return { label: label, href: '/onpage-seo/' + ONPAGE_VIEW[key], value: f.pct(last[key] || 0, 0), delta: prev ? (last[key] || 0) - (prev[key] || 0) : null, unit: 'pt' }; }; var kpis = [tile('all', t('home.onpage.overallseo')), tile('content', t('home.onpage.contentseo')), tile('technical', t('home.onpage.technicalseo')), tile('mobile', t('home.onpage.mobileseo')), tile('indexability', t('menu.onpage.indexability'))]; kpis[0].note = t('home.w.vs_prev_scan'); return { kind: 'kpis', kpis: kpis }; }); }, onpage_changes: function (w) { return $q.all([get('/rest/onpage/rules', { lang: $rootScope.user.site.locale.lang }), get('/rest/onpage/changes')]).then(function (r) { var rules = (r[0] && r[0].rules) || {}, list = ((r[1] && r[1].data) || []).filter(function (c) { return c.url; }); if (!list.length) { return state('empty', { text: t('home.changes.text_paragraph_no_changes') }); } return { kind: 'list', rows: list.slice(0, 6).map(function (c) { var rule = rules[c.rule] ? rules[c.rule].title : c.rule; return { mark: c.r === true ? 'ok' : (c.r === false ? 'bad' : 'info'), text: path(c.url), sub: rule + (c.to ? ': ' + c.to : ''), href: c.page_id && c.page_id.$oid ? '/onpage-seo/scan/' + c.page_id.$oid : null }; }) }; }); }, onpage_changes_donut: function (w) { return get('/rest/onpage/changes').then(function (x) { var n = { done: 0, issue: 0, change: 0 }; ((x && x.data) || []).forEach(function (c) { n[c.r === true ? 'done' : (c.r === false ? 'issue' : 'change')]++; }); var total = n.done + n.issue + n.change; if (!total) { return state('empty', { text: t('home.changes.text_paragraph_no_changes') }); } return { kind: 'counts', total: total, items: [ { label: t('home.w.count_issue'), href: '/onpage-seo/changes', n: n.issue, color: '#f59e0b', share: 100 * n.issue / total }, { label: t('home.w.count_done'), href: '/onpage-seo/changes', n: n.done, color: '#16a34a', share: 100 * n.done / total }, { label: t('home.w.count_change'), href: '/onpage-seo/changes', n: n.change, color: '#9ca3af', share: 100 * n.change / total } ] }; }); }, // Rank tracker: de posities uit de laatste meting, met het verschil met de meting daarvoor (zoals op /serp) ranktracer: function (w) { return $q.all([v1('serp.summary'), v1('serp.keywords', { limit: 10000 })]).then(function (r) { if (r[0].error || r[1].error) { return state('error'); } var x = r[0].data; if (x.trial) { return state('empty', { text: t('serp.trial_text') }); } var depth = x.depth || 20; // position null = niet in de gemeten top; nog geen meting = last_measured_month null var rows = (r[1].data || []).map(function (k) { var pos = k.last_measured_month ? k.position : undefined; return { q: k.keyword, href: '/serp/keywords?' + qs({ q: k.keyword }), cc: k.country, device: k.device, star: k.starred, waiting: !k.last_measured_month, pos: pos, label: pos === undefined ? '' : (pos === null ? '> ' + depth : pos), diff: k.change || 0 }; }); if (!rows.length) { return state('empty', { text: t('serp.empty_text'), href: '/serp/keywords', action: t('serp.add') }); } var measured = rows.filter(function (r) { return !r.waiting; }); rows.sort(function (a, b) { var pa = a.waiting ? 1000 : (a.pos === null ? 999 : a.pos), pb = b.waiting ? 1000 : (b.pos === null ? 999 : b.pos); return (b.star - a.star) || (pa - pb); }); var count = function (lo, hi) { return measured.filter(function (r) { return r.pos && r.pos >= lo && r.pos <= hi; }).length; }; var dist = [ { label: t('home.w.top3'), n: count(1, 3) }, { label: '4–10', n: count(4, 10) }, { label: '11–' + depth, n: count(11, depth) }, { label: t('home.w.not_found'), n: measured.filter(function (r) { return r.pos === null; }).length, grey: true } ]; dist.forEach(function (x2) { x2.share = measured.length ? 100 * x2.n / measured.length : 0; }); return { kind: 'rank', total: rows.length, top10: count(1, 10), dist: dist, rows: rows.slice(0, 6) }; }); }, uptime: function (w) { return uptimeView().then(function (x) { if (x.error) { return state('error'); } if (!x.enabled && !x.last_check_at) { return state('empty', { text: t('uptime.intro_text'), href: '/uptime', action: t('uptime.enable') }); } var cls = { up: 'bg-green-500', warn: 'bg-amber-400', blocked: 'bg-amber-400', failing: 'bg-amber-400', down: 'bg-red-500' }; var pill = { up: 'bg-green-100 text-green-800', down: 'bg-red-100 text-red-800', pending: 'bg-gray-100 text-gray-700' }; return { kind: 'uptime', stateText: t('uptime.state.' + x.state), stateCls: pill[x.state] || 'bg-amber-100 text-amber-800', since: x.state === 'up' && x.up_since ? t('home.w.since', { date: f.day(x.up_since) }) : (x.state === 'down' && x.down_since ? t('home.w.since', { date: f.day(x.down_since) }) : ''), bars: (x.bars || []).map(function (b) { return { cls: cls[b.outcome] || 'bg-gray-200', title: f.dayWeek(b.at) + ' ' + new Date(b.at).getHours() + ':00' + (b.outcome ? ' · ' + t('uptime.state.' + b.outcome) : '') }; }), first: x.bars && x.bars.length ? f.day(x.bars[0].at) : '', up7: x.uptime_7d === null ? '–' : f.pct(x.uptime_7d, x.uptime_7d === 100 ? 0 : 2), ttfb: x.ttfb_median_7d === null ? '–' : f.num(x.ttfb_median_7d) + ' ms', cert: x.cert_expires ? f.day(x.cert_expires) : '–' }; }); }, // de lijn uit analytics.view (part line): 28 dagen tot gisteren, eventueel één kanaal analytics4_timeline: function (w) { var m = w.metric || 'sessions'; var b = { part: 'line', metric: m, days: 28 }; if (GA4_CHANNELS[w.filter]) { b.filters = { channel: GA4_CHANNELS[w.filter] }; } return v1('analytics.view', b).then(function (x) { if (x.error) { return state('error'); } if (x.data.state === 'connect') { return connect('analytics'); } if (x.data.state !== 'ok') { return state('error'); } return dayLine(x.data.line.days, 'date', 'value', t('metrics.' + m), m); }); }, googleads_timeline: function (w) { var m = w.metric || 'Clicks'; return get('/rest/googlexads/report', { dimension: 'Date', end: 'TODAY', metrics: m, start: '30DAYSAGO', report: 'ACCOUNT_PERFORMANCE_REPORT' }).then(function (x) { if (x.action) { return connect('googleads'); } return x.error ? state('error') : dayLine(x.data, 'Date', m, t('metrics.' + m)); }); }, googleads_performance_chart: function (w) { var REPORT = { CampaignName: 'CAMPAIGN_PERFORMANCE_REPORT', AdGroupName: 'ADGROUP_PERFORMANCE_REPORT', Query: 'SEARCH_QUERY_PERFORMANCE_REPORT' }; var m = w.metric || 'Clicks', dim = w.dimension || 'CampaignName'; return get('/rest/googlexads/report', { dimension: dim, end: 'TODAY', metrics: m, start: '30DAYSAGO', report: REPORT[dim] || REPORT.CampaignName }).then(function (x) { if (x.action) { return connect('googleads'); } return x.error ? state('error') : topRows(x.data, dim, m, null, function (r) { return r[dim]; }); }); }, googleads_conversion_chart: function (w) { var m = w.metric || 'Conversions'; return get('/rest/googlexads/report', { dimension: 'ConversionTypeName', end: 'TODAY', metrics: m, start: '30DAYSAGO', report: 'ACCOUNT_PERFORMANCE_REPORT' }).then(function (x) { if (x.action) { return connect('googleads'); } return x.error ? state('error') : topRows(x.data, 'ConversionTypeName', m, null, function (r) { return r.ConversionTypeName; }); }); }, // De stand in één zin: bezoek uit Google (en AI, als dat er is) tegenover de vorige periode, plus de grootste kans. // AI kent alleen de vorige 28 dagen: tegenover vorig jaar telt alleen Google. summary: function (w) { var withAi = show.aiinsights && d.compare === 'prev'; return $q.all([ get('/rest/gscinsights/overview', { part: 'kpis', compare: d.compare }), withAi ? v1('aiinsights.traffic_summary') : $q.when(null), get('/rest/gscinsights/overview', { part: 'actions', compare: 'prev' }) ]).then(function (r) { if (gscState(r[0])) { return gscState(r[0]); } var g = r[0].totals, ai = r[1] && !r[1].error && r[1].data.ga4_connected ? r[1].data.sessions : null; var now = g.now.clicks + (ai ? ai.value : 0); var before = g.before ? g.before.clicks + (ai ? ai.previous : 0) : null; var ch = before ? 100 * (now - before) / before : null; var chance = ((r[2] && r[2].actions) || []).filter(function (a) { return a.kind === 'chance'; })[0]; return { kind: 'summary', href: '/search-insights', lead: t(ai ? 'home.w.summary_both' : 'home.w.summary_google', { n: f.num(now) }), change: ch === null ? '' : t(ch >= 0 ? 'home.w.summary_more' : 'home.w.summary_less', { pct: f.num(Math.abs(ch)) + '%' }), up: ch !== null && ch >= 0, vs: ch === null ? '' : t(d.compare === 'year' ? 'home.w.vs_year' : 'home.w.vs_prev'), chance: chance ? t('home.w.summary_chance', { q: chance.query, gain: f.num(chance.gain) }) : '', chanceHref: chance ? gsciView.exploreHref(chance.query) : null }; }); }, // Doe dit eerst: storingen en nieuwe scanfouten bovenaan, dan de kansen uit Google op opbrengst, dan AI todo: function (w) { var none = $q.when(null); return $q.all([ show.uptime ? uptimeView() : none, show.onpageseo ? get('/rest/onpage/changes') : none, show.gscinsights ? get('/rest/gscinsights/overview', { part: 'actions', compare: 'prev' }) : none, show.aiinsights ? v1('aiinsights.actions') : none ]).then(function (r) { var items = [], weekAgo = Date.now() - 7 * 86400000; if (r[0] && !r[0].error) { (r[0].incidents || []).filter(function (i) { return i.outcome === 'down' && i.start >= weekAgo; }).slice(0, 1).forEach(function (i) { var mins = Math.max(1, Math.round(((i.end || Date.now()) - i.start) / 60000)); items.push({ rank: 0, pill: t('home.w.pill_down'), pillCls: 'bg-red-100 text-red-800', tool: t('home.w.cat.uptime'), href: '/uptime', text: t(i.end ? 'home.w.todo_down' : 'home.w.todo_down_now', { date: f.day(i.start), time: new Date(i.start).getHours() + ':' + ('0' + new Date(i.start).getMinutes()).slice(-2), dur: mins >= 60 ? f.num(mins / 60, 1) + ' ' + t('uptime.unit_hour') : mins + ' min' }) }); }); } if (r[1] && r[1].data) { var bad = r[1].data.filter(function (c) { return c.r === false; }).length; if (bad) { items.push({ rank: 1, pill: t('home.w.pill_issues', { n: bad }), pillCls: 'bg-red-100 text-red-800', tool: t('home.w.cat.onpage'), href: '/onpage-seo/changes', text: t('home.w.todo_issues', { n: bad }) }); } } if (r[2] && !r[2].error && r[2].actions) { r[2].actions.slice(0, 4).forEach(function (a) { items.push({ rank: 2, gain: a.gain || 0, badge: a.kind, text: gsciView.actionText(a), end: a.gain ? '+' + f.num(a.gain) + ' ' + t('home.w.clicks') : '', tool: 'Search Insights', href: gsciView.exploreHref(a.query) }); }); } if (r[3] && !r[3].error && r[3].data) { aiView.actions(r[3].data).slice(0, 2).forEach(function (a) { items.push({ rank: 3, badge: a.kind, text: a.text, tool: 'AI Insights', href: a.href }); }); } if (!items.length) { return state('empty', { text: t('home.w.todo_nothing') }); } items.sort(function (a, b) { return (a.rank - b.rank) || ((b.gain || 0) - (a.gain || 0)); }); return { kind: 'list', rows: items.slice(0, 6) }; }); } }; // titel, groep en link van een widget op het dashboard; v = wat er getekend wordt var decorate = function (w) { var def = WIDGETS[w.type], cat = CAT[def.cat]; w.cat = def.cat; w.icon = cat.icon; w.tool = t('home.w.cat.' + def.cat); var mkey = w.type.indexOf('searchconsole') === 0 || w.type === 'si_chart' ? 'gscinsights.kpi.' : 'metrics.'; // de titel uit de widget zelf, met het kanaal erbij; een eigen titel (instellingen) gaat voor w.autoTitle = t('home.w.title.' + w.type, { metric: w.metric ? t(mkey + w.metric) : '', dimension: w.dimension ? t('dimensions.' + w.dimension) : '' }) + (w.filter && def.filters ? ' · ' + t('home.w.channel.' + w.filter) : ''); w.title = w.ownTitle || w.autoTitle; w.more = (def.link ? def.link(w) : def.more) || cat.href || null; w.hasSettings = true; w.filterOpts = def.filters ? [{ key: null, label: t('home.w.channel.all') }].concat(def.filters.map(function (x) { return { key: x, label: t('home.w.channel.' + x) }; })) : null; w.metricOpts = def.metrics ? def.metrics.map(function (m) { return { key: m, label: t(mkey + m) }; }) : null; w.dimensionOpts = def.dimensions && def.dimensions.length > 1 ? def.dimensions.map(function (x) { return { key: x, label: t('dimensions.' + x) }; }) : null; w.width = Math.round(100 * w.colspan / 12) + '%'; // vaste hoogte op de widget (zoals het oude dashboard): even hoog naast elkaar en niets verspringt bij het laden; // een zin of een rij tegels houdt zijn eigen hoogte w.fixed = def.kind !== 'sentence' && def.kind !== 'kpis'; return w; }; var load = function (w) { var mine = w.v = { state: 'loading' }; LOAD[w.type](w).then(function (v) { if (w.v === mine) { w.v = v; if (v.title && !w.ownTitle) { w.title = v.title; } } }, function () { if (w.v === mine) { w.v = state('error'); } }); }; var setWidgets = function (list) { d.widgets = (list || []).filter(function (x) { return offered(x.type); }).map(function (x) { return decorate({ type: x.type, metric: x.metric || WIDGETS[x.type].metric || null, dimension: x.dimension || WIDGETS[x.type].dimension || null, chart: x.chart || null, colspan: x.colspan || 6, filter: x.filter || null, ownTitle: x.title || '' }); }); d.hasCompare = d.widgets.some(function (w) { return w.type === 'si_kpis' || w.type === 'summary' || w.type === 'si_changes'; }); d.widgets.forEach(load); }; $http.get('/rest/home/widgets.list', { params: { websiteid: h.siteId() } }).then(function (res) { // niets te tonen van een eigen indeling (alleen tools die deze rol niet ziet): de standaard, zoals vroeger var mine = (res.data || []).filter(function (x) { return offered(x.type); }); return mine.length || !(res.data || []).length ? res : $http.get('/rest/home/widgets.list', { params: { websiteid: h.siteId(), which: 'fallback' } }); }).then(function (res) { $translate.onReady().then(function () { setWidgets(res.data); d.loaded = true; }); }); d.setCompare = function (c) { if (d.compare === c) { return; } d.compare = c; d.widgets.forEach(function (w) { if (w.type === 'si_kpis' || w.type === 'summary' || w.type === 'si_changes') { load(w); } }); }; // ------------------------------------------------------------------ bewaren var saveTimer = null; var save = function () { if (!d.canEdit) { return; } $timeout.cancel(saveTimer); saveTimer = $timeout(function () { $http.post('/rest/home/widgets.save', { websiteid: h.siteId(), widgets: cleanList() }); }, 400); }; var cleanList = function () { return d.widgets.map(function (w) { return { type: w.type, metric: w.metric, dimension: w.dimension, chart: w.chart, colspan: w.colspan, filter: w.filter, title: w.ownTitle }; }); }; // ------------------------------------------------------------------ bewerken d.toggleEdit = function () { d.edit = !d.edit; d.settings = null; d.confirm = null; }; d.remove = function (w) { d.widgets.splice(d.widgets.indexOf(w), 1); d.settings = null; save(); refreshPicker(); }; d.move = function (w, step) { var i = d.widgets.indexOf(w), j = i + step; if (j < 0 || j >= d.widgets.length) { return; } d.widgets.splice(i, 1); d.widgets.splice(j, 0, w); save(); }; // slepen: de gesleepte widget komt vóór of na de widget waar je hem loslaat, naar de helft waar de muis is // (links/rechts, bij een brede widget boven/onder); op een lege plek in het raster komt hij achteraan d.dragStart = function (w) { d.drag = w; d.settings = null; }; d.dragOver = function (w, after) { d.over = w; d.after = after; }; d.dropOn = function (w) { var from = d.drag, after = d.after; d.drag = d.over = null; if (!from || from === w) { return; } d.widgets.splice(d.widgets.indexOf(from), 1); d.widgets.splice(w ? d.widgets.indexOf(w) + (after ? 1 : 0) : d.widgets.length, 0, from); save(); }; d.dragEnd = function () { d.drag = d.over = null; }; // breedte: de dichtstbijzijnde van 25, 33, 50, 67, 75 en 100% d.setWidth = function (w, cols) { var best = WIDTHS[0]; WIDTHS.forEach(function (x) { if (Math.abs(x - cols) < Math.abs(best - cols)) { best = x; } }); if (w.colspan !== best) { w.colspan = best; w.width = Math.round(100 * best / 12) + '%'; return true; } return false; }; d.widthDone = function () { save(); }; d.openSettings = function (w) { d.settings = d.settings === w ? null : w; }; d.changed = function (w) { decorate(w); load(w); save(); }; // eigen titel: alleen de kop verandert, de gegevens niet d.retitle = function (w) { w.ownTitle = (w.ownTitle || '').slice(0, 60); w.title = w.ownTitle.trim() || w.autoTitle; save(); }; // ------------------------------------------------------------------ widgetkiezer var refreshPicker = function () { var p = d.picker; if (!p) { return; } var q = (p.q || '').toLowerCase().trim(); var on = {}; d.widgets.forEach(function (w) { on[w.type] = true; }); var types = Object.keys(WIDGETS).filter(offered); p.cats = [{ key: 'tip', icon: ICONS.star }, { key: 'all', icon: ICONS.all }].concat(CATS.filter(function (c) { return !c.tool || show[c.tool]; })) .concat([{ key: 'layouts', icon: ICONS.layout }]).map(function (c) { var n = c.key === 'layouts' ? p.layouts.length : types.filter(function (x) { return c.key === 'all' || (c.key === 'tip' ? WIDGETS[x].tip : WIDGETS[x].cat === c.key); }).length; return { key: c.key, icon: c.icon, label: t('home.w.cat.' + c.key), n: n }; }); p.showLayouts = p.cat === 'layouts' && !q; p.full = d.widgets.length >= MAX; p.cards = types.filter(function (x) { var w = WIDGETS[x]; if (q) { return (t('home.w.name.' + x) + ' ' + t('home.w.desc.' + x) + ' ' + t('home.w.cat.' + w.cat)).toLowerCase().indexOf(q) >= 0; } return p.cat === 'all' || (p.cat === 'tip' ? w.tip : w.cat === p.cat); }).map(function (x) { var w = WIDGETS[x]; return { type: x, kind: w.kind, name: t('home.w.name.' + x), desc: t('home.w.desc.' + x), tool: t('home.w.cat.' + w.cat), icon: CAT[w.cat].icon, size: Math.round(100 * w.span / 12) + '%', on: !!on[x] }; }); p.hint = q ? t('home.w.found', { n: p.cards.length, q: p.q }) : (p.showLayouts ? t('home.w.layouts_hint') : (p.cat === 'tip' ? t('home.w.tip_hint') : t('home.w.cards_hint'))); }; // een indeling als schets: een blokje per widget, zo breed als de widget var sketch = function (list) { return list.filter(function (x) { return offered(x.type); }).map(function (x) { return x.colspan || 6; }); }; var loadLayouts = function () { $q.all([ $http.get('/rest/home/widgets.list', { params: { websiteid: h.siteId(), which: 'default' } }), $http.get('/rest/home/template.list', { params: { websiteid: h.siteId() } }) ]).then(function (r) { var tl = r[1].data || {}; d.canTemplates = !!tl.can_edit; d.picker.layouts = [{ id: null, name: t('home.w.layout_default'), description: t('home.w.layout_default_desc'), widgets: r[0].data, blocks: sketch(r[0].data) }] .concat((tl.templates || []).map(function (x) { return angular.extend(x, { own: true, blocks: sketch(x.widgets) }); })); refreshPicker(); }); }; d.openPicker = function (cat) { d.picker = { cat: cat || 'tip', q: '', layouts: [], cards: [], cats: [] }; d.confirm = null; d.form = null; refreshPicker(); loadLayouts(); }; d.closePicker = function () { d.picker = null; d.confirm = null; d.form = null; }; d.pickCat = function (key) { d.picker.cat = key; d.picker.q = ''; d.confirm = null; refreshPicker(); }; d.search = function () { refreshPicker(); }; d.add = function (card) { if (card.on || d.widgets.length >= MAX) { return; } var def = WIDGETS[card.type]; var w = decorate({ type: card.type, metric: def.metric || null, dimension: def.dimension || null, chart: null, colspan: def.span }); d.widgets.push(w); d.hasCompare = d.hasCompare || w.type === 'si_kpis' || w.type === 'summary' || w.type === 'si_changes'; load(w); save(); d.closePicker(); d.edit = true; }; // een indeling vervangt de huidige: eerst bevestigen (in de kaart, geen venster van de browser) d.useLayout = function (l) { if (d.confirm !== l) { d.confirm = l; return; } setWidgets(l.widgets); save(); d.closePicker(); }; // indelingen van de white label: de huidige indeling bewaren, bijwerken of weghalen (rol 1-3 bij de reseller) d.newTemplate = function () { d.form = { name: '', description: '' }; }; d.saveTemplate = function (l) { var tpl = l ? { id: l.id, name: l.name, description: l.description, widgets: cleanList() } : { name: d.form.name, description: d.form.description, widgets: cleanList() }; $http.post('/rest/home/template.save', { websiteid: h.siteId(), template: tpl }).then(function () { d.form = null; loadLayouts(); }); }; d.deleteTemplate = function (l) { if (d.confirm !== 'del' + l.id) { d.confirm = 'del' + l.id; return; } $http.post('/rest/home/template.delete', { websiteid: h.siteId(), id: l.id }).then(function () { d.confirm = null; loadLayouts(); }); }; } ]); // Dashboard: een widget verslepen (HTML5 slepen; op een telefoon de pijltjes in de werkbalk). home-drag="w" app.directive('homeDrag', function () { return { link: function (scope, el, attrs) { var d = scope.d, node = el[0]; scope.$watch('d.edit', function (on) { node.setAttribute('draggable', on ? 'true' : 'false'); }); el.on('dragstart', function (e) { if (!d.edit) { return; } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'widget'); scope.$apply(function () { d.dragStart(scope.$eval(attrs.homeDrag)); }); }); el.on('dragover', function (e) { if (!d.drag) { return; } e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; var w = scope.$eval(attrs.homeDrag), r = node.getBoundingClientRect(); var after = w.colspan >= 9 ? e.clientY > r.top + r.height / 2 : e.clientX > r.left + r.width / 2; if (d.over !== w || d.after !== after) { scope.$apply(function () { d.dragOver(w, after); }); } }); el.on('drop', function (e) { e.preventDefault(); e.stopPropagation(); scope.$apply(function () { d.dropOn(scope.$eval(attrs.homeDrag)); }); }); el.on('dragend', function () { scope.$apply(function () { d.dragEnd(); }); }); } }; }); // Dashboard: loslaten op een lege plek in het raster zet de widget achteraan. home-drop-end app.directive('homeDropEnd', function () { return { link: function (scope, el) { var d = scope.d; el.on('dragover', function (e) { if (!d.drag) { return; } e.preventDefault(); if (d.over) { scope.$apply(function () { d.over = null; }); } }); el.on('drop', function (e) { if (!d.drag) { return; } e.preventDefault(); scope.$apply(function () { d.dropOn(null); }); }); } }; }); // Dashboard: breedte kiezen door aan de rechterrand te trekken; klikt vast op 25/33/50/67/75/100%. home-resize="w" app.directive('homeResize', function () { return { link: function (scope, el, attrs) { var start = null; el.on('pointerdown', function (e) { var grid = el[0].closest('[data-home-grid]'), item = el[0].closest('[draggable]'); if (!grid) { return; } e.preventDefault(); e.stopPropagation(); el[0].setPointerCapture(e.pointerId); if (item) { item.setAttribute('draggable', 'false'); } var w = scope.$eval(attrs.homeResize); start = { x: e.clientX, cols: w.colspan, col: (grid.clientWidth + 24) / 12, item: item, w: w }; }); el.on('pointermove', function (e) { if (!start) { return; } var cols = start.cols + (e.clientX - start.x) / start.col; if (scope.d.setWidth(start.w, cols)) { scope.$apply(); } }); var end = function () { if (!start) { return; } if (start.item) { start.item.setAttribute('draggable', 'true'); } start = null; scope.$apply(function () { scope.d.widthDone(); }); }; el.on('pointerup', end); el.on('pointercancel', end); } }; }); app.controller('help', ['$scope', '$timeout', '$http', '$rootScope', 'h', function($scope, $timeout, $http, $rootScope, h) { $http.get('https://www.marketingtracer.com/help/listjson', { params: { lang: $rootScope.user.site.locale.lang, } }).then(function successCallback(res) { $scope.topics = res.data; }); $scope.getText = function(_id, sub_id, subtopic) { $http.get('https://www.marketingtracer.com/help/subtopicjson', { params: { lang: $rootScope.user.site.locale.lang, _id: _id, sub_id: sub_id, } }).then(function successCallback(res) { subtopic.html = res.data; }); } var users = { userList: function() { $http.get('/rest/user/reseller.site.list', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.users = res.data; }); }, matchUser: function(UserId) { for (var i in $scope.users) { if ($scope.users[i]._id.$oid == UserId) { return $scope.users[i].name; } } return ''; } } } ]); app.controller("ranktracker", [ "$scope", "$timeout", "$http", "$rootScope", "$filter", "h", function ($scope, $timeout, $http, $rootScope, $filter, h) { $scope.rankings = []; $scope.page = { bFilterStar: false, label: "", lgchart: "", labels: {}, filter: {}, sortType: "q", collapse: {}, chartlg: false, locale: $rootScope.user.site.locale, fnChartLg: function (c) { this.chartlg = this.chartlg == c ? false : c; }, sortReverse: false, fnSort: function (s) { if ($scope.page.sortType == s) { $scope.page.sortReverse = !$scope.page.sortReverse; } else { $scope.page.sortReverse = false; } $scope.page.sortType = s; }, provider: "google", }; $scope.fnCustomDate = { init: function () { var d = new Date(); d.setMonth(d.getMonth() - 1); $scope.customdate = { start: d, end: new Date(), show: false }; }, set: function () { $scope.customdate.show = false; $rootScope.reportdates.start = Math.round($scope.customdate.start.getTime() / 1000); $rootScope.reportdates.end = Math.round($scope.customdate.end.getTime() / 1000); $rootScope.reportdates.description = "Custom"; $scope.getKeywords(); }, }; $scope.dateoptions = [ { start: false, end: false, description: "All times" }, { start: "TODAY", end: "TODAY", description: "Today" }, { start: "YESTYERDAY", end: "YESTYERDAY", description: "Yesterday" }, { start: "7DAYSAGO", end: "TODAY", description: "Past 7 days" }, { start: "14DAYSAGO", end: "TODAY", description: "Past 14 days" }, { start: "30DAYSAGO", end: "TODAY", description: "Past 30 days" }, { start: "1YEARAGO", end: "TODAY", description: "Past year" }, { start: "THISMONTH", end: "TODAY", description: "This month" }, { start: "LASTMONTH", end: "TODAY", description: "Previous month" }, ]; if (!$rootScope.reportdates) { $rootScope.reportdates = { start: false, end: false, description: "All time" }; } $scope.fnDateSelection = function (reportdates) { $rootScope.reportdates.start = reportdates.start; $rootScope.reportdates.end = reportdates.end; $rootScope.reportdates.description = reportdates.description; $scope.getKeywords(); $scope.getVisibility(); }; $scope.do = function (obj, method, param) { if (obj == "fnSingle") { fnSingle[method](param); } else { fnMultiple[method](param); } }; var fnSingle = { getParams: function (ranking) { return { websiteid: h.siteId(), ids: [ranking._id.$oid], }; }, pre_options: function (ranking) { ranking.pre_options = !ranking.pre_options; if (ranking.pre_options) { $http .get("/rest/rank-tracker/history", { params: { websiteid: h.siteId(), ranking_id: ranking._id.$oid, provider: $scope.page.provider, start: $rootScope.reportdates.start, end: $rootScope.reportdates.end, }, }) .then(function successCallback(res) { var data = res.data; }); } }, options: function (ranking) { $http .post( "/rest/rank-tracker/edit", angular.extend( { "summary.google.start": ranking.summary.google.start, "summary.googlemobile.start": ranking.summary.googlemobile.start, "meta.label": ranking.meta.label, url: ranking.url, fields: ["summary.google.start", "summary.googlemobile.start", "meta.label", "url"], }, fnSingle.getParams(ranking) ) ) .then(function successCallback(res) { $scope.getKeywords(); }); }, sanitize: function (ranking) { $http.post("/rest/rank-tracker/sanitize", angular.extend({ "q": ranking.q }, fnSingle.getParams(ranking))).then(function successCallback(res) { $scope.getKeywords(); }); }, delete: function (ranking) { $http.post("/rest/rank-tracker/delete", fnSingle.getParams(ranking)).then(function successCallback(res) { $scope.getKeywords(); }); }, star: function (ranking) { $http.post("/rest/rank-tracker/edit", angular.extend({ "meta.star": !ranking.meta.star, fields: ["meta.star"] }, fnSingle.getParams(ranking))).then(function successCallback(res) { $scope.getKeywords(); }); }, }; var fnMultiple = { getParams: function (ranking) { var ids = []; for (var i in $scope.rankings) { if ($scope.rankings[i].checked) { ids.push($scope.rankings[i]._id.$oid); } } return { websiteid: h.siteId(), ids: ids, }; }, multioptions: function () { $scope.page.multioptions = false; for (var i in $scope.rankings) { if ($scope.rankings[i].checked) { $scope.page.multioptions = true; break; } } }, check: function (ranking) { ranking.checked = !ranking.checked; fnMultiple.multioptions(); }, checkall: function (label) { $scope.page.labels[label] = !$scope.page.labels[label]; for (var i in $scope.rankings) { if ($scope.rankings[i].meta.label == label) { $scope.rankings[i].checked = $scope.page.labels[label]; } } fnMultiple.multioptions(); }, options: function (ranking) { $http .post( "/rest/rank-tracker/edit", angular.extend( { "meta.label": $scope.page.label, fields: ["meta.label"], }, fnMultiple.getParams(ranking) ) ) .then(function successCallback(res) { $scope.getKeywords(); }); }, delete: function (ranking) { $http.post("/rest/rank-tracker/delete", fnMultiple.getParams(ranking)).then(function successCallback(res) { $scope.getKeywords(); }); }, resetvolume: function (ranking) { $http.post("/rest/rank-tracker/resetvolume", fnMultiple.getParams(ranking)).then(function successCallback(res) { $scope.getKeywords(); }); }, export: function (ranking) { $http.post("/rest/rank-tracker/export", fnMultiple.getParams(ranking)).then(function successCallback(res) { var element = document.createElement("a"); element.setAttribute("href", "data:text/html;," + encodeURIComponent(res.data.csv)); element.setAttribute("download", "export.csv"); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); }); }, exportxls: function (ranking) { $http.post("/rest/rank-tracker/exportxls", fnMultiple.getParams(ranking)).then(function successCallback(res) { var element = document.createElement("a"); element.setAttribute("href", "data:text/html;," + encodeURIComponent(res.data.csv)); element.setAttribute("download", "export.xls"); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); }); }, }; // ranking grafiek'heschiedenis $scope.fnRankingHistory = function (ranking, provider) { if (ranking.active && $scope.page.provider == provider) { ranking.active = false; return; } $scope.page.provider = provider; for (var x in $scope.rankings) { $scope.rankings[x].active = false; } ranking.active = true; $http .get("/rest/rank-tracker/history", { params: { websiteid: h.siteId(), ranking_id: ranking._id.$oid, provider: $scope.page.provider, start: $rootScope.reportdates.start, end: $rootScope.reportdates.end, }, }) .then(function successCallback(res) { var data = res.data; var graphs = []; var _d = {}; var d = []; let urlkeyexists = {}; for (var i in data) { if (data[i]?.data?.length > 0 || typeof data[i]?.data === "object") { if (data[i]?.data?.length === 0) { // stupid array identifies as object! } else { console.log(data[i]?.data); console.log(data[i]?.data?.length); console.log(typeof data[i]?.data); for (var y in data[i].data) { _d[data[i].data[y].date] = _d[data[i].data[y].date] || { date: data[i].data[y].date }; _d[data[i].data[y].date][i] = data[i].data[y].pos; } graphs.push({ balloonText: data[i].url + "
position: [[value]]", title: data[i].url, bullet: "round", bulletBorderAlpha: 1, bulletColor: "#FFFFFF", useLineColorForBulletBorder: true, fillAlphas: 0, lineThickness: 2, lineAlpha: 1, bulletSize: 7, valueField: i, connect: false, }); } } } for (var i in _d) { d.push(_d[i]); } ranking.chart = { data: d, type: "serial", theme: "light", marginLeft: 0, marginRight: 0, marginTop: 10, categoryField: "date", rotate: false, pathToImages: "vendor/amchart/images/", dataDateFormat: "YYYYMMDD", legend: { position: "bottom", enabled: true, useGraphSettings: true, }, categoryAxis: { gridPosition: "start", parseDates: true, labelRotation: 45, }, valueAxes: [ { reversed: true, position: "left", axisAlpha: 0, labelsEnabled: true, minimum: 1, }, ], graphs: graphs, }; }); }; // haal alle sleutelwoorden op $scope.getKeywords = function (a) { // all active plans $scope.urls = {}; var startDate = $scope.custominit ? $scope.custominit.start : $rootScope.reportdates.start; $scope.page.bFilterStar = $scope.custominit ? $scope.custominit.bFilterStar : $scope.page.bFilterStar; if ($scope.page.bFilterStar == true) { $scope.page.filter.meta = { star: true }; } $http .get("/rest/rank-tracker/list", { params: { websiteid: h.siteId(), start: startDate, end: $rootScope.reportdates.end, }, }) .then(function successCallback(res) { $scope.page.rankinglength = res.data.length; if ($scope.page.rankinglength === 0) { //$scope.fnModal("rank-tracker/modal.addkeyword"); } $scope.change = { nverlies: 0, nwinst: 0, nsame: 0, verlies: 0, winst: 0, totaal: 0, n: 0 }; $scope.pie = { top3: 0, top10: 0, top100: 0, overig: 0 }; $scope._rankings = angular.copy(res.data); //$scope.rankings = res.data; $scope.sortranking(); }); }; $scope.switchstar = function () { if ($scope.page.filter.meta) { delete $scope.page.filter.meta; } else { $scope.page.filter.meta = { star: true }; } $scope.sortranking(); }; $scope.sortranking = function () { $scope.rankings = $filter("filter")($scope._rankings, $scope.page.filter); if ($scope.rankings.length > 0) { for (var i in $scope.rankings) { if ($scope.rankings[i].active) { var active = i; } } } var sorted = {}; // some stats var totalenhancements = {}; for (var i in $scope.rankings) { var ranking = $scope.rankings[i]; ranking.meta.label = ranking.meta.label || "--"; if (ranking?.url && ranking.url.indexOf("http") != 0) { ranking.url = "https://" + ranking.url; } var tmpg = ranking?.summary?.google?.pos || 100; var tmpgm = ranking?.summary?.googlemobile?.pos || 100; ranking.summary = ranking?.summary || { google: { pos: tmpg }, googlemobile: { pos: tmpgm } }; //ranking.summary.google = ranking?.summary?.google || {pos:100} //ranking.summary.googlemobile.pos = ranking.summary.googlemobile.pos || 100 if (active && i == active) { $scope.fnRankingHistory(ranking, $scope.page.provider); } // kan weg if (ranking.summary.google.pos < 4) { ++$scope.pie.top3; } else if (ranking.summary.google.pos < 11) { ++$scope.pie.top10; } else if (ranking.summary.google.pos < 100) { ++$scope.pie.top100; } else { ++$scope.pie.overig; } //health ranking.health = 100 - ((ranking.summary.google.worst - ranking.summary.google.pos + 0.5) / (ranking.summary.google.worst - ranking.summary.google.best)) * 100; if (ranking.health > 100) { ranking.health = 100; } sorted[ranking.meta.label] = sorted[ranking.meta.label] || []; sorted[ranking.meta.label].push(ranking); //change if (ranking.summary.google.start > ranking.summary.google.pos) { $scope.change.nwinst += 1; $scope.change.winst += ranking.summary.google.start - ranking.summary.google.pos; } else if (ranking.summary.google.start < ranking.summary.google.pos) { $scope.change.nverlies += 1; $scope.change.verlies += ranking.summary.google.pos - ranking.summary.google.start; } else { $scope.change.nsame += 1; } // enhancements var enhancements = {}; if (typeof ranking.summary.google.enhancements == "object" && ranking.summary.google.enhancements != null) { if (typeof ranking.summary.google.enhancements.other == "object") { ranking.summary.google.enhancements.other = ranking.summary.google.enhancements.other || {}; ranking.summary.google.enhancements.other = Object.values(ranking.summary.google.enhancements.other); } if (typeof ranking.summary.google.enhancements.my == "object") { ranking.summary.google.enhancements.my = ranking.summary.google.enhancements.my || {}; ranking.summary.google.enhancements.my = Object.values(ranking.summary.google.enhancements.my); } ranking.summary.google.enhancements.other = ranking.summary.google.enhancements.other || []; ranking.summary.google.enhancements.my = ranking.summary.google.enhancements.my || []; ranking.summary.google.enhancements.other.forEach(function (v, k) { totalenhancements[v] = totalenhancements[v] || { v: v, my: 0, other: 0 }; totalenhancements[v].other += 1; enhancements[v] = enhancements[v] || { v: v, my: 0, other: 0 }; enhancements[v].other += 1; }); ranking.summary.google.enhancements.my.forEach(function (v, k) { totalenhancements[v] = totalenhancements[v] || { v: v, my: 0, other: 0 }; totalenhancements[v].my += 1; enhancements[v] = enhancements[v] || { v: v, my: 0, other: 0 }; enhancements[v].my += 1; }); } ranking.enhancements = Object.values(enhancements).sort(function (a, b) { return a.v < b.v ? -1 : 1; }); $scope.totalenhancements = Object.values(totalenhancements).sort(function (a, b) { return a.v < b.v ? -1 : 1; }); $scope.change.n += 1; $scope.change.totaal += ranking.summary.google.start - ranking.summary.google.pos; } let sortedranking = []; Object.keys(sorted).forEach(function (k) { let _sorted = sorted[k]; sortedranking.push({ label: k, rankings: _sorted }); }); $scope.sortedranking = sortedranking; }; $scope.charts = {}; $scope.changes = {}; var chart = { small: { type: "serial", categoryField: "date", dataDateFormat: "YYYYMMDD", autoMargins: false, categoryAxis: { parseDates: true, gridAlpha: 0, axisAlpha: 0 }, valueAxes: [{ gridAlpha: 0, axisAlpha: 0, color: "#FFFFFF", stackType: "regular" }], }, large: { type: "serial", categoryField: "date", dataDateFormat: "YYYYMMDD", autoMargins: true, categoryAxis: { gridPosition: "start", parseDates: true, gridThickness: 0 }, valueAxes: [{ position: "left", axisAlpha: 0, labelsEnabled: true, dashLength: 4, color: "#dedede", stackType: "regular" }], }, visibility: function (data, type) { $scope.charts.visibility = angular.extend({}, chart[type], { data: data, graphs: [ { balloonText: "Visibility: [[value]]%", fillAlphas: 0, lineAlpha: 1, bulletAlpha: 0.7, valueField: "visibility", lineColor: h.colors()[1], }, ], }); $scope.charts.visibilitylg = angular.extend({}, chart["large"], { data: data, graphs: [ { balloonText: "Visibility: [[value]]%", lineAlpha: 1, valueField: "visibility", lineColor: h.colors()[1], bullet: "round", bulletBorderAlpha: 1, bulletColor: "#FFFFFF", useLineColorForBulletBorder: true, fillAlphas: 0, lineThickness: 2, lineAlpha: 1, bulletSize: 7, }, ], }); $scope.changes.visibility = { cur: data[data.length - 1].visibility, start: data[0].visibility, dif: data[data.length - 1].visibility - data[0].visibility }; }, avgpos: function (data, type) { var _data = data.reduce(function (acc, cur) { if (cur.avg) { acc.push(cur); } return acc; }, []); if (_data.length == 1) { _data.unshift({ date: "20191031", avg: 0 }); } $scope.charts.avgpos = angular.extend({}, chart[type], { data: _data, graphs: [ { balloonText: "Avg position: [[value]]", lineAlpha: 1, valueField: "avg", lineColor: h.colors()[3], }, ], valueAxes: [{ gridAlpha: 0, axisAlpha: 0, color: "#FFFFFF", stackType: "regular", reversed: true }], }); $scope.charts.avgposlg = angular.extend({}, chart["large"], { data: _data, graphs: [ { balloonText: "Avg position: [[value]]", valueField: "avg", lineColor: h.colors()[3], lineAlpha: 1, bullet: "round", bulletBorderAlpha: 1, bulletColor: "#FFFFFF", useLineColorForBulletBorder: true, fillAlphas: 0, lineThickness: 2, lineAlpha: 1, bulletSize: 7, }, ], valueAxes: [{ gridAlpha: 0, axisAlpha: 0, color: "#FFFFFF", stackType: "regular", reversed: true }], }); $scope.changes.avgpos = { cur: _data[_data.length - 1].avg, start: _data[0].avg, dif: (_data[_data.length - 1].avg - _data[0].avg) * -1 }; }, enhancement: function (data, type) { var _data = data.reduce(function (acc, cur) { if (cur.my_enh || cur.other_enh) { acc.push(cur); } return acc; }, []); if (_data.length == 1) { _data.unshift({ date: "20191031", my_enh: 0, other_enh: 0 }); } $scope.charts.enhancement = angular.extend({}, chart[type], { data: _data, graphs: [ { balloonText: "My enhancements: [[value]]", fillAlphas: 0.6, lineAlpha: 1, valueField: "my_enh", lineColor: h.colors()[4], }, { balloonText: "Total enhancements: [[value]]", fillAlphas: 0.6, lineAlpha: 1, valueField: "other_enh", lineColor: "#DDDDDD", }, ], }); $scope.charts.enhancementlg = angular.extend({}, chart["large"], { data: _data, graphs: [ { balloonText: "My enhancements: [[value]]", bullet: "round", lineAlpha: 1, bullet: "round", bulletBorderAlpha: 1, bulletColor: "#FFFFFF", useLineColorForBulletBorder: true, fillAlphas: 0.6, lineThickness: 2, lineAlpha: 1, bulletSize: 7, valueField: "my_enh", lineColor: h.colors()[4], }, { balloonText: "Total enhancements: [[value]]", bullet: "round", lineAlpha: 1, bullet: "round", bulletBorderAlpha: 1, bulletColor: "#FFFFFF", useLineColorForBulletBorder: true, fillAlphas: 0.6, lineThickness: 2, lineAlpha: 1, bulletSize: 7, valueField: "other_enh", lineColor: "#DDDDDD", }, ], }); $scope.changes.enhancement = { cur: _data[_data.length - 1]?.my_enh, start: _data[0]?.my_enh, dif: _data[_data.length - 1]?.my_enh - _data[0]?.my_enh }; }, top: function (data, type) { var _data = data.reduce(function (acc, cur) { if (cur.top3 || cur.top10 || cur.top50 || cur.top100 || cur.norank) { acc.push(cur); } return acc; }, []); $scope.charts.top = angular.extend({}, chart[type], { data: _data, graphs: [ { balloonText: "Not ranked: [[value]]", fillAlphas: 0.6, lineAlpha: 0.6, valueField: "norank", lineColor: "#DDDDDD" }, { balloonText: "Top100: [[value]]", fillAlphas: 0.25, lineAlpha: 0.25, valueField: "top100", lineColor: h.colors()[2] }, { balloonText: "Top50: [[value]]", fillAlphas: 0.45, lineAlpha: 0.45, valueField: "top50", lineColor: h.colors()[2] }, { balloonText: "Top10: [[value]]", fillAlphas: 0.65, lineAlpha: 0.65, valueField: "top10", lineColor: h.colors()[2] }, { balloonText: "Top3: [[value]]", fillAlphas: 0.85, lineAlpha: 0.85, valueField: "top3", lineColor: h.colors()[2] }, { balloonText: "Top1: [[value]]", fillAlphas: 1, lineAlpha: 1, valueField: "top1", lineColor: h.colors()[2] }, ], }); $scope.changes.top = { cur: _data[_data.length - 1], start: _data[0], dif: { top1: _data[_data.length - 1].top1 - _data[0].top1 || 0, top3: _data[_data.length - 1].top3 - _data[0].top3 || 0, top10: _data[_data.length - 1].top10 - _data[0].top10 || 0, top50: _data[_data.length - 1].top50 - _data[0].top50 || 0, top100: _data[_data.length - 1].top100 - _data[0].top100 || 0, norank: _data[_data.length - 1].norank - _data[0].norank || 0, }, }; }, }; // haal alle sleutelwoorden op $scope.getVisibility = function (a) { $http .get("/rest/rank-tracker/visibility", { params: { websiteid: h.siteId(), start: $rootScope.reportdates.start, end: $rootScope.reportdates.end, }, }) .then(function successCallback(res) { chart.avgpos(res.data, "small"); chart.visibility(res.data, "small"); chart.enhancement(res.data, "small"); chart.top(res.data, "small"); }); }; $scope.fnAddKeywords = function () { $http .post("/rest/rank-tracker/add", { keywords: $scope.page.addkeywords, locale: $scope.page.locale, websiteid: h.siteId(), }) .then(function successCallback(res) { $scope.getKeywords(); $scope.fnCloseModal(); $scope.page.addkeywords = ""; }); }; $scope.getKeywords(); $scope.getVisibility(); $http.get("/rest/locale/language.list").then(function successCallback(res) { $scope.languages = res.data; }); $http.get("/rest/locale/country.list").then(function successCallback(res) { $scope.countries = res.data; }); $scope.fnSearchCity = function (q) { $http.get("/rest/locale/city.list", { params: { q: q } }).then(function successCallback(res) { $scope.cities = res.data; }); }; $scope.setCity = function (city) { $scope.page.locale.city = city.city; $scope.page.locale.uule = city.uule; $scope.cities = []; }; }, ]); app.controller('ranktrackerinsights', ['$scope', '$timeout', '$http', '$rootScope', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $filter, h) { $scope.rankings = []; if($scope.custominit){ $scope.page = {'timeframe': '31d'}; $scope.mds = [ { 'title': '31-day mean - position improvement', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': '-median.s31dc', 'lengthindex': '31dl', 'comp': 'gt' }, { 'title': '31-day mean - position loss', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': 'median.s31dc', 'lengthindex': '31dw', 'comp': 'lt' }, ]; } else { $scope.page = {'timeframe': '3d'}; $scope.mds = [ { 'title': '3-day mean - position improvement', 'timeframe':'3d', 'start': 'r3d', 'end': 'r6d', 'dif': 's3dc', 'vol': 's3dvol', 'order': '-median.s3dc', 'lengthindex': '3dw', 'comp': 'gt' }, { 'title': '3-day mean - traffic improvement', 'timeframe':'3d', 'start': 'r3d', 'end': 'r6d', 'dif': 's3dc', 'vol': 's3dvol', 'order': '-median.s3dvol', 'lengthindex': '3dw', 'comp': 'gt' }, { 'title': '3-day mean - position loss', 'timeframe':'3d', 'start': 'r3d', 'end': 'r6d', 'dif': 's3dc', 'vol': 's3dvol', 'order': 'median.s3dc', 'lengthindex': '3dl', 'comp': 'lt' }, { 'title': '3-day mean - traffic loss', 'timeframe':'3d', 'start': 'r3d', 'end': 'r6d', 'dif': 's3dc', 'vol': 's3dvol', 'order': 'median.s3dvol', 'lengthindex': '3dl', 'comp': 'lt' }, { 'title': '7-day mean - position improvement', 'timeframe':'7d', 'start': 'r7d', 'end': 'r14d', 'dif': 's7dc', 'vol': 's7dvol', 'order': '-median.s7dc', 'lengthindex': '7dw', 'comp': 'gt' }, { 'title': '7-day mean - traffic improvement', 'timeframe':'7d', 'start': 'r7d', 'end': 'r14d', 'dif': 's7dc', 'vol': 's7dvol', 'order': '-median.s7dvol', 'lengthindex': '7dw', 'comp': 'gt' }, { 'title': '7-day mean - position loss', 'timeframe':'7d', 'start': 'r7d', 'end': 'r14d', 'dif': 's7dc', 'vol': 's7dvol', 'order': 'median.s7dc', 'lengthindex': '7dl', 'comp': 'lt' }, { 'title': '7-day mean - traffic loss', 'timeframe':'7d', 'start': 'r7d', 'end': 'r14d', 'dif': 's7dc', 'vol': 's7dvol', 'order': 'median.s7dvol', 'lengthindex': '7dl', 'comp': 'lt' }, { 'title': '31-day mean - position improvement', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': '-median.s31dc', 'lengthindex': '31dl', 'comp': 'gt' }, { 'title': '31-day mean - traffic improvement', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': '-median.s31dvol', 'lengthindex': '31dl', 'comp': 'gt' }, { 'title': '31-day mean - position loss', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': 'median.s31dc', 'lengthindex': '31dw', 'comp': 'lt' }, { 'title': '31-day mean - traffic loss', 'timeframe':'31d', 'start': 'r31d', 'end': 'r62d', 'dif': 's31dc', 'vol': 's31dvol', 'order': 'median.s31dvol', 'lengthindex': '31dw', 'comp': 'lt' }, ]; } $scope.exists = function(prop, comp) { return function(item) { return item.median.hasOwnProperty(prop) && (comp == 'lt') ? item.median[prop] < 0 : item.median[prop] > 0 } } var competitors = { listinsights: function() { $http.get("/rest/rank-tracker/median", { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.insights = res.data; $scope.lengths = $scope.insights.reduce(function(acc, cur) { if (cur.median.s3dc > 0) { acc['3dw'] += 1; } else if (cur.median.s3dc < 0) { acc['3dl'] += 1; } if (cur.median.s7dc > 0) { acc['7dw'] += 1; } else if (cur.median.s7dc < 0) { acc['7dl'] += 1; } if (cur.median.s7dc > 0) { acc['31dw'] += 1; } else if (cur.median.s31dc < 0) { acc['31dl'] += 1; } return acc; }, { '3dl': 0, '3dw': 0, '7dl': 0, '7dw': 0, '31dl': 0, '31dw': 0 }) }); }, } competitors.listinsights(); // ranking grafiek'heschiedenis $scope.fnRankingHistory = function(ranking, provider) { if (ranking.active && $scope.page.provider == provider) { ranking.active = false; return; } $scope.page.provider = provider; for (var x in $scope.rankings) { $scope.rankings[x].active = false; } ranking.active = true; $http.get("/rest/rank-tracker/history", { params: { websiteid: h.siteId(), ranking_id: ranking._id.$oid, provider: 'google', } }).then(function successCallback(res) { var data = res.data; var graphs = []; var _d = {}; var d = []; for (var i in data) { for (var y in data[i].data) { _d[data[i].data[y].date] = _d[data[i].data[y].date] || { 'date': data[i].data[y].date }; _d[data[i].data[y].date][i] = data[i].data[y].pos; } graphs.push({ balloonText: data[i].url + "
position: [[value]]", "title": data[i].url, "bullet": "round", "bulletBorderAlpha": 1, "bulletColor": "#FFFFFF", "useLineColorForBulletBorder": true, "fillAlphas": 0, "lineThickness": 2, "lineAlpha": 1, "bulletSize": 7, "valueField": i, "connect": false, }) } for (var i in _d) { d.push(_d[i]); } ranking.chart = { data: d, type: 'serial', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, categoryField: 'date', rotate: false, pathToImages: 'vendor/amchart/images/', dataDateFormat: 'YYYYMMDD', legend: { position: "bottom", enabled: true, "useGraphSettings": true, }, categoryAxis: { gridPosition: "start", parseDates: true, labelRotation: 45 }, valueAxes: [{ reversed: true, position: "left", axisAlpha: 0, labelsEnabled: true, minimum: 1, }], graphs: graphs, } }); } // set competitors $scope.fnAddCompetitors = function() { $http.post('/rest/rank-tracker/competitors.edit', { competitors: $scope.competitors, websiteid: h.siteId() }).then(function successCallback(res) { competitors.listinsights(); competitors.listkeywords(); $scope.fnCloseModal(); }); } } ]); app.controller('ranktrackercompetitors', ['$scope', '$timeout', '$http', '$rootScope', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $filter, h) { $scope.rankings = []; $scope.blazak = {}; $scope.page = { label: '', labels: {}, table: { 'curpage': 0, 'itemsperpage': 14, 'offset': 0, 'filter': {}, 'order': { 'k': 'url', 'r': false }, 'fnOrder': function(k, b) { this.order.r = (k == this.order.k) ? !this.order.r : b; this.order.k = k; }, fnSetOffset: function() { this.offset = this.itemsperpage * (this.curpage - 1); }, fnFilterProperty: function() { var pages = angular.copy($rootScope._pages); $scope.pages = $filter('filter')(pages, $scope.page.table.filter); } }, date: 'alltime', dates: ['alltime', '4weeks', '4months', 'thismonth'], tab: 'my', provider: 'google' } $scope.do = function(obj, method, param) { if (obj == 'fnSingle') { fnSingle[method](param); } else { fnMultiple[method](param); } } $scope.export = function () { $http.post("/rest/rank-tracker/exportcompetitors", { websiteid: h.siteId(), }).then(function successCallback(res) { var element = document.createElement("a"); element.setAttribute("href", "data:text/html;," + encodeURIComponent(res.data.csv)); element.setAttribute("download", "export.csv"); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); }); }; var competitors = { listcompetitors: function() { $http.get("/rest/rank-tracker/competitors.list", { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.competitors = res.data; if ($scope.competitors.length === 0 && $rootScope.user.site.role < 6) { $scope.fnModal('rank-tracker/modal.editcompetitors'); } }); }, listkeywords: function() { if ($scope.rankings.length > 0) { for (var i in $scope.rankings) { if ($scope.rankings[i].active) { var active = i; } } } var sorted = {}; // all active plans $scope.urls = {} $http.get('/rest/rank-tracker/competitors.listkeywords', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.page.rankinglength = res.data.length; $scope.rankings = res.data; // some stats for (var i in $scope.rankings) { var ranking = $scope.rankings[i]; ranking.summary.google.pos = ranking.summary.google.pos || 100 ranking.summary.googlemobile.pos = ranking.summary.googlemobile.pos || 100 sorted[ranking.meta.label] = sorted[ranking.meta.label] || []; sorted[ranking.meta.label].push(ranking); } $scope.sortedranking = sorted; }); } } competitors.listcompetitors(); competitors.listkeywords(); // ranking grafiek'heschiedenis $scope.fnRankingHistory = function(ranking, provider) { provider = 'google'; console.log(ranking); if (ranking.active && $scope.page.provider == provider) { ranking.active = false; return; } $scope.page.provider = provider; for (var x in $scope.rankings) { $scope.rankings[x].active = false; } ranking.active = true; $http.get("/rest/rank-tracker/competitors.history", { params: { websiteid: h.siteId(), ranking_id: ranking._id.$oid, provider: $scope.page.provider, } }).then(function successCallback(res) { var data = res.data; var graphs = []; var _d = {}; var d = []; for (var i in data) { console.log(data[i]); for (var y in data[i].data) { _d[data[i].data[y].date] = _d[data[i].data[y].date] || { 'date': data[i].data[y].date }; _d[data[i].data[y].date][i] = data[i].data[y].pos; } graphs.push({ balloonText: data[i].url + "
position: [[value]]", "title": data[i].url, "bullet": "round", "bulletBorderAlpha": 1, "bulletColor": "#FFFFFF", "useLineColorForBulletBorder": true, "fillAlphas": 0, "lineThickness": 2, "lineAlpha": 1, "bulletSize": 7, "valueField": i, "connect": false, }) } for (var i in _d) { d.push(_d[i]); } ranking.chart = { data: d, type: 'serial', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, fontFamily: 'Roboto', categoryField: 'date', rotate: false, pathToImages: 'vendor/amchart/images/', dataDateFormat: 'YYYYMMDD', legend: { position: "bottom", enabled: true, "useGraphSettings": true, }, categoryAxis: { gridPosition: "start", parseDates: true, labelRotation: 45 }, valueAxes: [{ reversed: true, position: "left", axisAlpha: 0, labelsEnabled: true, minimum: 1, }], graphs: graphs, } }); } // set competitors $scope.fnAddCompetitors = function() { $http.post('/rest/rank-tracker/competitors.edit', { competitors: $scope.competitors, websiteid: h.siteId() }).then(function successCallback(res) { competitors.listcompetitors(); competitors.listkeywords(); $scope.fnCloseModal(); }); } } ]); // Linkbuilding v2 (ontwerp "Linkbuilding ontwerp", 2026-10-30): /linkbuilding (Overzicht), /linkbuilding/pijplijn, // /linkbuilding/outreach, /linkbuilding/bewaakt. Ook als rapportsectie (newlayout/linkbuilding/report.html, custominit.linkbuilding). // Server: API v1, /rest/v1/linkbuilding.* (rest/include/api/linkbuilding.php): dezelfde endpoints als klanten en de MCP // gebruiken; elk antwoord is {data, meta} (datacontract: id, ISO-tijden, volledige url's). Regels in // rest/include/linkbuilding.php. Bouwblokken: js/ui.js (docs/ui.md). app.controller('linkbuilding', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', '$q', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, $q, h, mtFmt) { var report = $scope.custominit && $scope.custominit.linkbuilding ? $scope.custominit : null; if (!report && (!$rootScope.user || !$rootScope.user.site || !$rootScope.user.site.show || !$rootScope.user.site.show.linkbuilding)) { $location.path('/'); return; } var TABS = { '': 'overview', pijplijn: 'pipeline', outreach: 'outreach', bewaakt: 'monitor' }; var BASE = '/linkbuilding'; var STAGES = ['found', 'sent', 'agreed', 'live']; // de pijplijn: live links staan bij Bewaakte links (Arjen: "live lijkt me geen pijplijn"); slepen naar Live of Afgewezen kan wel var BOARD = ['found', 'sent', 'agreed']; var STATUS_OF = { found: 'open', sent: 'pending', agreed: 'agreed', live: 'done', rejected: 'rejected' }; var t = function (k, v) { return $translate.instant(k, v); }; var VIEW_KEY = 'mt.lb.view'; var view = 'board'; try { view = window.localStorage.getItem(VIEW_KEY) || 'board'; } catch (e) { view = 'board'; } var lb = $scope.lb = { report: !!report, tab: report ? 'overview' : (TABS[$routeParams.tab || ''] || 'overview'), tabs: [], f: mtFmt, d: {}, view: view, q: '', sel: {}, searching: {}, busy: false, notice: null, error: null, drawer: null, canEdit: (+($rootScope.user.site.role || 99)) <= 5, canSmtp: (+($rootScope.user.site.role || 99)) <= 4, stages: STAGES, statuses: ['open', 'pending', 'agreed', 'done', 'lost', 'rejected'] }; var buildTabs = function () { var n = lb.d.links ? lb.d.links.filter(function (l) { return l.stage !== 'live'; }).length : null; var live = lb.d.links ? lb.d.links.filter(function (l) { return l.stage === 'live'; }).length : null; lb.tabs = [ { key: 'overview', label: t('lb.tab.overview'), href: BASE }, { key: 'pipeline', label: t('lb.tab.pipeline'), href: BASE + '/pijplijn', count: n }, { key: 'outreach', label: t('lb.tab.outreach'), href: BASE + '/outreach' }, { key: 'monitor', label: t('lb.tab.monitor'), href: BASE + '/bewaakt', count: live } ]; }; buildTabs(); $scope.$on('$destroy', $rootScope.$on('$translateChangeSuccess', buildTabs)); // API v1: POST /rest/v1/linkbuilding. met JSON; het antwoord is {data, meta}, een fout {error: {code, message}} var api = function (part, body) { return $http.post('/rest/v1/linkbuilding.' + part, angular.extend({ site_id: h.siteId() }, body || {})).then(function (res) { return res.data; }); }; // ---- laden per tab ---- lb.load = function () { var parts; if (report) { var end = new Date((($rootScope.reportdates || {}).end || Date.now() / 1000) * 1000); parts = { overview: api('overview', { month: end.getFullYear() + '-' + ('0' + (end.getMonth() + 1)).slice(-2) }) }; } else { // alleen wat dit tabblad nodig heeft; de pagina haalt alle links in één keer (de pijplijn telt en filtert in de browser) parts = { overview: function () { return { overview: api('overview') }; }, pipeline: function () { return { links: api('links', { limit: 5000 }), sequences: api('sequences'), smtp: api('smtp') }; }, monitor: function () { return { links: api('links', { limit: 5000 }) }; }, outreach: function () { return { sequences: api('sequences'), queue: api('queue'), smtp: api('smtp') }; } }[lb.tab](); } $q.all(parts).then(function (res) { angular.forEach(res, function (r, k) { lb.d[k] = r.data; }); lb.smtpReady = lb.d.smtp ? !!lb.d.smtp.smtp.verified_at : true; lb.error = null; lb.build(); buildTabs(); }, function () { lb.error = 'load'; }); }; // ---- afgeleide gegevens (één keer per laad, niet in de template) ---- lb.build = function () { if (lb.d.overview) { var o = lb.d.overview; var max = 1; o.months.forEach(function (m) { max = Math.max(max, m.new + m.lost); }); lb.months = o.months.map(function (m) { return { label: mtFmt.month(m.month + '-01'), newPct: m.new / max * 100, lostPct: m.lost / max * 100, n: m.new, l: m.lost }; }); var f = o.funnel_90_days; lb.funnel = STAGES.map(function (s) { return { key: s, n: f[s], pct: f.found ? Math.round(f[s] / f.found * 100) : 0 }; }); lb.rate = f.sent ? Math.round(f.agreed / f.sent * 100) : null; } if (lb.d.links) { var q = (lb.q || '').toLowerCase(); var all = lb.d.links.filter(function (l) { return !q || l.domain.toLowerCase().indexOf(q) > -1 || (l.anchor || '').toLowerCase().indexOf(q) > -1 || l.url.toLowerCase().indexOf(q) > -1; }); lb.columns = BOARD.map(function (s) { var rows = all.filter(function (l) { return l.stage === s; }); return { key: s, count: rows.length, rows: rows.slice(0, 60), more: Math.max(0, rows.length - 60) }; }); lb.dropCounts = { live: all.filter(function (l) { return l.stage === 'live'; }).length, rejected: all.filter(function (l) { return l.stage === 'rejected'; }).length }; lb.list = all.filter(function (l) { return l.stage !== 'live'; }).slice(0, 500); var mon = all.filter(function (l) { return l.stage === 'live' || l.stage === 'lost'; }); lb.monitor = mon.slice(0, 500); lb.monitorCounts = { ok: mon.filter(function (l) { return l.stage === 'live' && l.check.state !== 'changed' && l.check.state !== 'missing'; }).length, changed: mon.filter(function (l) { return l.check.state === 'changed'; }).length, doubt: mon.filter(function (l) { return l.stage === 'live' && l.check.state === 'missing'; }).length, lost: mon.filter(function (l) { return l.stage === 'lost'; }).length }; } }; $scope.$watch('lb.q', function (n, o) { if (n !== o) { lb.build(); } }); // ---- pijplijn ---- lb.setView = function (v) { lb.view = v; try { window.localStorage.setItem(VIEW_KEY, v); } catch (e) { /* geen opslag */ } }; lb.selected = function () { return Object.keys(lb.sel).filter(function (k) { return lb.sel[k]; }); }; lb.clearSel = function () { lb.sel = {}; }; // klik selecteert, shift-klik selecteert alles tussen de vorige klik en deze (in dezelfde kolom of lijst) var lastPick = null; lb.pick = function (l, ev, rows) { var ids = (rows || []).map(function (r) { return r.id; }); var a = ids.indexOf(lastPick); var b = ids.indexOf(l.id); if (ev && ev.shiftKey && a > -1 && b > -1) { ids.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(function (id) { lb.sel[id] = true; }); try { window.getSelection().removeAllRanges(); } catch (e) { /* niets */ } } else { lb.sel[l.id] = !lb.sel[l.id]; } lastPick = l.id; }; // slepen naar een kolom: de gesleepte link, of de hele selectie als hij daarin zit lb.dropTo = function (stage, id) { if (!lb.canEdit || !STATUS_OF[stage]) { return; } var ids = lb.sel[id] ? lb.selected() : [id]; var rows = lb.d.links.filter(function (l) { return ids.indexOf(l.id) > -1 && l.stage !== stage; }); if (!rows.length) { return; } rows.forEach(function (l) { l.stage = stage; }); lb.build(); api('links_bulk', { link_ids: rows.map(function (l) { return l.id; }), action: 'status', status: STATUS_OF[stage] }).then(function () { lb.notice = t('lb.moved', { n: rows.length, stage: t('lb.stage.' + stage) }); lb.sel = {}; lb.load(); }, function () { lb.error = 'save'; lb.load(); }); }; // contact zoeken: één rij voor alle klikken, site na site (nooit tegelijk). Vooraf gaan ze in de wachtrij van de // cron als vangnet (die wacht een kwartier), dan zoekt de pagina ze een voor een en werkt elk kaartje meteen bij. var contactRow = []; var contactSum = null; var contactRunning = false; var nextContact = function () { var id = contactRow.shift(); if (!id) { contactRunning = false; if (contactSum) { lb.notice = t('lb.contact_result', contactSum) + (contactSum.queued ? ' ' + t('lb.contact_queued', contactSum) : ''); } contactSum = null; lb.load(); return; } delete lb.contactWait[id]; lb.searching[id] = true; api('links_bulk', { link_ids: [id], action: 'contact' }).then(function (r) { var c = r.data.contact || {}; ['email', 'form', 'none', 'queued'].forEach(function (k) { contactSum[k] += c[k] || 0; }); var got = (r.data.links || []).filter(function (x) { return x.link_id === id; })[0]; (lb.d.links || []).forEach(function (l) { if (l.id === id && got) { l.email = got.email; l.contact_form = got.contact_form; l.contact_checked_at = got.contact_checked_at; l.contact_queued = false; } }); if (got && lb.drawer && lb.drawer.type === 'link' && lb.drawer.l.id === id) { lb.drawer.l.email = lb.drawer.l.email || got.email; lb.drawer.l.contact_form = lb.drawer.l.contact_form || got.contact_form; } }, function () { lb.error = 'save'; }).finally(function () { delete lb.searching[id]; nextContact(); }); }; lb.contactWait = {}; lb.findContact = function (ids) { ids = (ids || lb.selected()).filter(function (id) { return !lb.contactWait[id] && !lb.searching[id]; }); if (!ids.length) { return; } ids.forEach(function (id) { lb.contactWait[id] = true; }); contactSum = contactSum || { email: 0, form: 0, none: 0, queued: 0 }; lb.sel = {}; api('links_bulk', { link_ids: ids, action: 'contact_queue' }).finally(function () { contactRow = contactRow.concat(ids); if (!contactRunning) { contactRunning = true; nextContact(); } }); }; lb.contactState = function (l) { if (lb.searching[l.id]) { return 'searching'; } if (lb.contactWait[l.id]) { return 'waiting'; } if (l.contact_queued) { return 'queued'; } if (l.email) { return 'email'; } if (l.contact_form) { return 'form'; } return l.contact_checked_at ? 'none' : 'never'; }; lb.bulk = function (action, extra) { var ids = lb.selected(); if (!ids.length) { return; } if (action === 'contact') { lb.findContact(ids); return; } lb.busy = true; api('links_bulk', angular.extend({ link_ids: ids, action: action }, extra || {})).then(function (r) { lb.busy = false; lb.notice = t('lb.done_n', { n: r.data.count }); lb.sel = {}; lb.load(); }, function () { lb.busy = false; lb.error = 'save'; }); }; lb.bulkStatus = null; lb.bulkSeq = null; // ---- paneel: één link ---- lb.open = function (l) { lb.drawer = { type: 'link', l: angular.copy(l), busy: false }; }; lb.saveLink = function () { var x = lb.drawer.l; lb.drawer.busy = true; api('link_save', { link_id: x.id, url: x.url, my_url: x.my_url || '', anchor: x.anchor || '', note: x.note || '', status: x.status, email: x.email || '', contact_form: x.contact_form || '' }).then(function () { lb.drawer = null; lb.notice = t('lb.saved'); lb.load(); }, function () { lb.drawer.busy = false; lb.drawer.error = true; }); }; lb.close = function () { lb.drawer = null; }; // ---- paneel: prospects toevoegen ---- lb.openAdd = function (kind) { lb.drawer = { type: 'add', kind: kind || 'serp', text: '', url: '', live: false, feed: '', tag: '', picked: {}, busy: false, result: null }; if (lb.drawer.kind === 'serp') { lb.loadSerp(); } if (lb.drawer.kind === 'alerts') { lb.loadFeeds(); } }; lb.addKind = function (k) { lb.drawer.kind = k; if (k === 'serp' && !lb.drawer.serp) { lb.loadSerp(); } if (k === 'alerts' && !lb.drawer.feeds) { lb.loadFeeds(); } }; lb.loadSerp = function () { api('prospects_serp').then(function (r) { if (lb.drawer) { lb.drawer.serp = r.data; } }); }; lb.loadFeeds = function () { api('feeds').then(function (r) { if (lb.drawer) { lb.drawer.feeds = r.data; } }); }; var lastSerp = null; lb.pickSerp = function (r, ev) { var p = lb.drawer.picked; var doms = (lb.drawer.serp || []).map(function (x) { return x.domain; }); var a = doms.indexOf(lastSerp); var b = doms.indexOf(r.domain); if (ev && ev.shiftKey && a > -1 && b > -1) { doms.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(function (d) { p[d] = true; }); try { window.getSelection().removeAllRanges(); } catch (e) { /* niets */ } } else { p[r.domain] = !p[r.domain]; } lastSerp = r.domain; }; lb.pickAllSerp = function () { var all = lb.pickedCount() === (lb.drawer.serp || []).length; lb.drawer.picked = {}; if (!all) { (lb.drawer.serp || []).forEach(function (x) { lb.drawer.picked[x.domain] = true; }); } }; lb.pickedCount = function () { return lb.drawer && lb.drawer.picked ? Object.keys(lb.drawer.picked).filter(function (k) { return lb.drawer.picked[k]; }).length : 0; }; lb.addProspects = function () { var d = lb.drawer; var body = {}; if (d.kind === 'serp') { body.rows = (d.serp || []).filter(function (r) { return d.picked[r.domain]; }).map(function (r) { return { url: r.url }; }); body.tag = 'rank tracker'; } else if (d.kind === 'paste') { body.text = d.text; body.tag = d.tag; } else if (d.kind === 'single') { body.rows = [{ url: d.url, anchor: d.anchor, my_url: d.my_url, live: d.live }]; } d.busy = true; api('prospects_add', body).then(function (r) { d.busy = false; d.result = r.data; lb.load(); }, function () { d.busy = false; d.error = true; }); }; lb.addFeed = function () { var d = lb.drawer; d.busy = true; api('feed_add', { url: d.feed, tag: d.tag || 'google alerts' }).then(function () { d.busy = false; d.feed = ''; lb.loadFeeds(); lb.load(); }, function () { d.busy = false; d.error = true; }); }; // ---- outreach ---- lb.openSeq = function (s) { lb.drawer = { type: 'seq', s: s ? angular.copy(s) : { id: '', name: '', steps: [{ subject: '', body: '', after_workdays: 0 }] }, busy: false }; }; lb.addStep = function () { var steps = lb.drawer.s.steps; if (steps.length < 3) { steps.push({ subject: '', body: '', after_workdays: steps.length === 1 ? 4 : 7 }); } }; lb.saveSeq = function () { var s = lb.drawer.s; lb.drawer.busy = true; api('sequence_save', { id: s.id || '', name: s.name, steps: s.steps.map(function (st) { return { subject: st.subject, body: st.body, after_workdays: st.after_workdays }; }) }).then(function () { lb.drawer = null; lb.notice = t('lb.saved'); lb.load(); }, function () { lb.drawer.busy = false; lb.drawer.error = true; }); }; lb.deleteSeq = function () { lb.drawer.busy = true; api('sequence_delete', { id: lb.drawer.s.id }).then(function (r) { lb.drawer = null; lb.notice = t('lb.seq_deleted', { n: r.data.stopped || 0 }); lb.load(); }, function () { lb.drawer.busy = false; lb.drawer.error = true; }); }; lb.stopQueued = function (row) { api('links_bulk', { link_ids: [row.link_id], action: 'stop' }).then(lb.load); }; lb.openSmtp = function () { var s = angular.copy((lb.d.smtp || {}).smtp || {}); s.password = ''; lb.drawer = { type: 'smtp', s: s, busy: false, test: null }; }; lb.saveSmtp = function (thenTest) { var s = lb.drawer.s; lb.drawer.busy = true; lb.drawer.error = null; api('smtp_save', { host: s.host, port: s.port, secure: s.secure, user: s.user, password: s.password, from_name: s.from_name, from_email: s.from_email, day_limit: s.day_limit }).then(function () { if (!thenTest) { lb.drawer = null; lb.load(); return; } return api('smtp_test').then(function (r) { lb.drawer.busy = false; lb.drawer.test = r.data; lb.load(); }); }, function (res) { lb.drawer.busy = false; lb.drawer.error = (res.data && res.data.error && res.data.error.code) || 'error'; }); }; lb.editBlocklist = function () { lb.drawer = { type: 'block', text: ((lb.d.smtp || {}).blocklist || []).join('\n'), busy: false }; }; lb.saveBlocklist = function () { lb.drawer.busy = true; api('blocklist_save', { text: lb.drawer.text }).then(function () { lb.drawer = null; lb.load(); }); }; // ---- weergave ---- lb.when = function (ms) { if (!ms) { return '–'; } var d = new Date(ms); return mtFmt.dayWeek(d) + ' ' + d.toLocaleTimeString($translate.use(), { hour: '2-digit', minute: '2-digit' }); }; lb.day = function (ms) { return ms ? mtFmt.dayWeek(new Date(ms)) : '–'; }; // een adres zonder https:// en www., voor op een kaartje lb.bare = function (u) { return String(u || '').replace(/^https?:\/\/(www\.)?/, ''); }; lb.next = function (l) { if (l.mail.next_at) { return t('lb.next.mail', { step: l.mail.step + 1, when: lb.day(l.mail.next_at) }); } if (l.stage === 'found') { return l.email ? t('lb.next.ready') : ''; } if (l.stage === 'live') { return l.check.last_at ? t('lb.next.seen', { when: lb.day(l.check.last_at) }) : t('lb.next.first_check'); } if (l.stage === 'agreed') { return t('lb.next.agreed'); } if (l.stage === 'sent') { return t('lb.next.waiting'); } return ''; }; lb.histColor = { found: 'bg-green-600', changed: 'bg-amber-500', missing: 'bg-gray-400', error: 'bg-gray-200' }; lb.load(); }]); // slepen in de pijplijn (HTML5 drag and drop; op een telefoon blijft de selectiebalk) app.directive('lbDrag', function () { return { restrict: 'A', link: function (scope, el, attrs) { el.on('dragstart', function (e) { var id = scope.$eval(attrs.lbDrag); var dt = (e.originalEvent || e).dataTransfer; if (!id || !dt) { e.preventDefault(); return; } dt.setData('text/plain', id); dt.effectAllowed = 'move'; el.addClass('opacity-40'); }); el.on('dragend', function () { el.removeClass('opacity-40'); }); } }; }); app.directive('lbDrop', ['$parse', function ($parse) { return { restrict: 'A', link: function (scope, el, attrs) { var fn = $parse(attrs.lbDrop); var depth = 0; var off = function () { depth = 0; el.removeClass('ring-2 ring-blue-500'); }; el.on('dragenter', function () { depth++; el.addClass('ring-2 ring-blue-500'); }); el.on('dragleave', function () { if (--depth <= 0) { off(); } }); el.on('dragover', function (e) { e.preventDefault(); var dt = (e.originalEvent || e).dataTransfer; if (dt) { dt.dropEffect = 'move'; } }); el.on('drop', function (e) { e.preventDefault(); off(); var dt = (e.originalEvent || e).dataTransfer; var id = dt ? dt.getData('text/plain') : ''; if (/^[a-f0-9]{24}$/.test(id)) { scope.$apply(function () { fn(scope, { $id: id }); }); } }); } }; }]); app.controller('user', ['$scope', '$rootScope', '$http', '$location', '$translate', 'h', function($scope, $rootScope, $http, $location, $translate, h) { $scope.curUser = {}; $scope.page = {}; var _cb = { loginfailed: function() { $scope.page.message = { 'key': 'user.global.message_login_failed', class: 'text-warning' } }, loginok: function(res) { $rootScope.user = res.user; // kwam je via een link (app.js bewaarde het adres), dan daarheen; alleen een pad binnen de app var next = null; try { next = window.sessionStorage.getItem('mt.afterLogin'); window.sessionStorage.removeItem('mt.afterLogin'); } catch (e) { next = null; } $location.url(next && /^\/(?!\/)/.test(next) && next.indexOf('/user/') !== 0 ? next : '/'); }, registrationnotvalid: function() { $scope.page.message = { 'key': 'user.global.message_registration_failed', class: 'text-warning' } }, registrationexists: function() { $scope.page.message = { 'key': 'user.global.message_login_exists', class: 'text-warning' } }, registrationok: function(res) { $rootScope.user = res.user; $scope.fnGoto('/sites/first'); //h.login($location.path('/sites/first')); }, recoverfailed: function() { $scope.page.message = { 'key': 'user.global.message_recover_failed', class: 'text-warning' } }, recoverok: function() { $scope.page.message = { 'key': 'user.global.message_recover_ok', class: 'text-warning' } }, } $scope.checklogin = function() { if ($rootScope.user._id) { $location.path('/'); } if (!$rootScope.user.reseller.config.own && $location.path() == '/user/register') { $location.path('/user/login'); } } $scope.userdetails = { init: function() { $http.get('/rest/user/user.get').then(function successCallback(res) { $scope.curUser = res.data; }); }, 'set': function() { $http.post('/rest/user/user.set', $scope.curUser); }, 'invoice': function(key) { $scope.fnGoto('/rest/pay/invoice.link?type=user&key=' + key) } } $scope.fnLogin = function() { $http.post('/rest/user/login', $scope.curUser).then(function successCallback(res) { _cb[res.data.cb](res.data) }); } $scope.fnRegister = function() { $http.post('/rest/user/register', $scope.curUser).then(function successCallback(res) { _cb[res.data.cb](res.data) }); } $scope.fnRecover = function() { $http.post('/rest/user/recover', $scope.curUser).then(function successCallback(res) { _cb[res.data.cb](res.data) }); } } ]); app.controller('sites', ['$scope', '$rootScope', '$http', '$location', 'h', function($scope, $rootScope, $http, $location, h) { $scope.rand = function(min,max){ return Math.floor(Math.random() * (max - min + 1) + min) } $scope.bShowAdminDelete = function(site) { return (site.status.expire > (Date.now() / 1000)) ? false : true; } $scope.page = { pending: false, orderstate: 'init' }; if ($scope.orderstate) { $scope.page.orderstate = $scope.orderstate; } $scope.curSite = {}; $scope.do = function(obj, method, param) { if (obj == 'fnSite') { fnSite[method](param); } } var fnSite = { 'wemessage': function() { $scope.fnGoto('/rest/pay/wemessage.start') }, 'list': function() { $http.get('/rest/sites/list').then(function successCallback(res) { $scope.sites = res.data; }); }, 'add': function() { $http.post('/rest/sites/add', $scope.curSite).then(function successCallback(res) { _cb[res.data.cb](res.data) }); }, 'adminupgrade': function() { $http.post('/rest/sites/adminupgrade', { websiteid: h.siteId() }) }, 'unpark': function() { $http.post('/rest/sites/adminunpark', { websiteid: h.siteId() }).then(function successCallback(res) { h.login(function() { $scope.fnClick('/') }); }); }, 'admindelete': function(site) { $http.post('/rest/sites/remove', site).then(function successCallback(res) { side.hidden = true; }); }, 'delete': function(site) { $http.post('/rest/sites/remove', $scope.curSite).then(function successCallback(res) { $scope.curSite = {}; h.login(function() {}); fnSite.list(); }); }, 'pre_edit': function(site) { $http.get('/rest/locale/language.list').then(function successCallback(res) { $scope.languages = res.data; }); $http.get('/rest/locale/country.list').then(function successCallback(res) { $scope.countries = res.data; }); $scope.curSite = site; $scope.fnModal('sites/modal/edit'); }, 'edit': function(site) { $http.post('/rest/sites/edit', site).then(function successCallback(res) { }); }, 'order_options': function() { $http.get('/rest/pay/options.list').then(function successCallback(res) { $scope.options = res.data; var index = ($rootScope.user.site.locale.lang == 'nl') ? 0 : 1; $scope.order = { 'currency': $scope.options.currency[index], 'method': $scope.options.methods[index] }; }); }, 'pre_order': function(package) { $http.post('/rest/sites/edit', site).then(function successCallback(res) { }); }, } var _cb = { domainvalid: function(site) { h.login(function() { $location.path('/'); }); }, domaininvalid: function() { $scope.page.message = { html: 'The domain is invalid', class: 'text-warning' } }, domainduplicate: function() { $scope.page.message = { html: 'This domain is not available for trials versions.', class: 'text-warning' } }, deletefailed: function() { $scope.page.message = { html: 'Registration failed, the user with email adress ' + $scope.curUser.email + ' allready exists. Did you mean to ', class: 'text-danger' } }, } } ]); app.controller('site', ['$scope', '$timeout', '$http', '$rootScope', '$location', '$anchorScroll', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $location, $anchorScroll, $filter, h) { $scope.page = { newteammember: {}, newmenu: {} }; $scope.roles = [ { 'role': 4, 'name': 'owner' }, { 'role': 5, 'name': 'normal user' }, { 'role': 6, 'name': 'guest' }, ] $scope.do = function(obj, method, param) { if (obj == 'fnSite') { fnSite[method](param); } else if (obj == 'fnPage') { fnPage[method](param); } } $http.get('/rest/locale/language.list').then(function successCallback(res) { $scope.languages = res.data; }); $http.get('/rest/locale/country.list').then(function successCallback(res) { $scope.countries = res.data; }); var fnSite = { get: function() { $http.get('/rest/sites/get', { params: { 'websiteid': h.siteId(), } }).then(function successCallback(res) { if (res.data._id) { $scope.page.tab = 'edit'; $scope.user.site.users = res.data.users; $scope.user.site.names = res.data.names; $scope.user.site.api = res.data.api; } else { $scope.page.tab = 'intro'; } }); }, 'searchcity': function(q) { $http.get('/rest/locale/city.list', { 'params': { 'q': q } }).then(function successCallback(res) { $scope.cities = res.data; }); }, 'setcity': function(city) { $scope.user.site.locale.city = city.city; $scope.user.site.locale.uule = city.uule; $scope.cities = []; }, 'add': function() { $http.post('/rest/sites/teammember.add', angular.extend({ 'websiteid': h.siteId() }, $scope.page.newteammember) ).then(function successCallback(res) { $scope.page.newteammember = {}; fnSite.get(); }); }, 'park': function() { $http.post('/rest/sites/adminpark', { websiteid: h.siteId() }).then(function successCallback(res) { h.login(function() { $scope.fnClick('/') }); }); }, 'delete': function(teammember) { $http.post('/rest/sites/teammember.delete', angular.extend({ 'websiteid': h.siteId() }, teammember) ).then(function successCallback(res) { fnSite.get(); }); }, 'update': function(reseller) { $http.post('/rest/sites/teammembers.update', angular.extend({ 'websiteid': h.siteId() }, $scope.user.site) ).then(function successCallback(res) { fnSite.get(); }); }, 'edit': function(site) { if (!$scope.user.site.locale.city || !$scope.user.site.locale.uule) { $scope.user.site.locale.uule = ''; $scope.user.site.locale.city = ''; } $http.post('/rest/sites/edit', angular.extend({ 'websiteid': h.siteId() }, $scope.user.site) ).then(function successCallback(res) { }); }, 'addmenu': function() { $scope.user.site.menu = $scope.user.site.menu || []; $scope.user.site.menu.push($scope.page.newmenu); fnSite.edit($scope.user.site); }, 'removemenu': function(index) { $scope.user.site.menu.splice(index, 1); fnSite.edit($scope.user.site); }, } fnSite.get(); } ]); // Site-instellingen, Koppelingen: wat aan deze site gekoppeld is, om weg te halen (rest/data/connections) app.controller('siteConnections', ['$scope', '$http', '$translate', 'h', function ($scope, $http, $translate, h) { var s = $scope.conn = { list: null }; var load = function () { $http.get('/rest/connections/list', { params: { websiteid: h.siteId() } }).then(function (res) { s.list = res.data.connections || []; }); }; // eerst een bevestiging in de rij zelf (geen browserdialoog), dan weg s.remove = function (c) { if (s.asking !== c.key) { s.asking = c.key; return; } s.asking = null; c.busy = true; $http.post('/rest/connections/remove', { websiteid: h.siteId(), key: c.key }).then(function () { $scope.$root.$broadcast('siteConnectionsChanged', c.key); load(); }, function () { c.busy = false; }); }; load(); }]); app.controller('contentlist', ['$scope', '$http', '$rootScope', '$filter', '$location', 'h', '$timeout', function($scope, $http, $rootScope, $filter, $location, h, $timeout) { $scope.page = { 'newmodel': { 'lang': $rootScope.user.site.locale.lang } }; var content = { listModels: function() { $http.get('/rest/content/model.list', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.models = res.data.data; content.checkProgress(); }); }, addModel: function(model) { $http.post('/rest/content/model.add', angular.extend({ websiteid: h.siteId() }, model)).then(function successCallback(res) { $scope.page.newmodel = {} content.listModels(); }); }, createModel: function(model) { $http.post('/rest/content/model.create', angular.extend({ websiteid: h.siteId() }, model)).then(function successCallback(res) { model.status = 'checking'; }); }, deleteModel: function(model) { $http.post('/rest/content/model.delete', angular.extend({ websiteid: h.siteId() }, model)).then(function successCallback(res) { content.listModels(); }); }, checkProgress: function() { var l = false; for (var i in $scope.models) { if ($scope.models[i].status == 'pending') { l = true; } } if (l) { $timeout(function() { content.listModels() }, 5000); } } } $scope.do = function(method, param) { content[method](param); } content.listModels() } ]); app.directive('wrapInTag', function () { return { restrict: 'A', link: function (scope, elm, attr) { console.log(attr); var tag = attr.tag || 'strong'; var words = eval(attr.words) || []; var text = attr.text; for (var i = 0; i < words.length; i++) { text = text.replace(words[i], '<' + tag + '>' + words[i] + ''); } elm.html(text); } } }); app.controller('contentmodel', ['$scope', '$http', '$rootScope', '$filter', '$location', 'h', '$timeout', function ($scope, $http, $rootScope, $filter, $location, h, $timeout) { $scope.page = { states: {}, 'tab': 'research', 'type': 'article', 'tones': { "informative": true, "persuasive": false, "emotional": false, "witty": true, "funny": false, "professional": true, "informal": false } }; $scope.mypage = {}; $scope.page.states.explore = { 'open': false, 'cluster': 1 } var model = { getModel: function () { $http.get('/rest/content/model.get', { params: { 'websiteid': h.siteId(), 'modelid': $scope.modelid } }).then(function successCallback(res) { $scope.q = res.data.q; $scope.clusters = res.data.clusters; $scope.dna = res.data.dna; $scope.wordfreq = res.data.wordfreq; $scope.trans = res.data.trans; $scope.r = res.data.r; $scope.lsi = res.data.lsi; $scope.target = res.data.target; $scope.pages = res.data.pages; $scope.write = res.data.write; $scope.x = {}; for (var i in $scope.wordfreq) { $scope.x[$scope.wordfreq[i].word] = $scope.wordfreq[i].avg; $scope.wordfreq[i].maxminavg = $scope.wordfreq[i].max - $scope.wordfreq[i].avg } charts.tfchart(); charts.dnaChart(); model.getMyPage(); }); }, setMyPage: function () { $scope.mypage.pending = true; $http.post('/rest/content/model.setmypage', angular.extend({ websiteid: h.siteId(), 'modelid': $scope.modelid }, $scope.mypage)).then(function successCallback(res) { model.getMyPage() }); }, writestatus: function () { var l = false; $http.get('/rest/content/model.get', { params: { 'websiteid': h.siteId(), 'modelid': $scope.modelid } }).then(function successCallback(res) { if (res.data.write.status == 'pending') { l = true; } if (l) { $timeout(function () { model.writestatus(); }, 5000); } else { model.getMyPage(); $scope.write = res.data.write; } }); }, write: function () { $scope.write = $scope.write || {}; $scope.write.status = 'pending'; $scope.page.tab = 'write'; $http.post('/rest/content/model.pre_write', angular.extend({ websiteid: h.siteId(), 'modelid': $scope.modelid }, { 'type': $scope.page.type, 'tones': $scope.page.tones })).then(function successCallback(res) { model.writestatus(); // need to set up checks here! }); }, getMyPage: function () { $scope.mypage.pending = true; $http.get('/rest/content/model.getmypage', { params: { 'websiteid': h.siteId(), 'modelid': $scope.modelid } }).then(function successCallback(res) { $scope.mypage = res.data; if (res.data.mna) { for (var i in $scope.wordfreq) { var word = $scope.wordfreq[i].word; $scope.wordfreq[i].my = res.data.counter[word] || 0; } $scope.mna = res.data.mna; charts.tfchart(); charts.dnaChart(); $scope.mypage.pending = false; model.getSpamandSuggestions(); $scope.page.tab = 'write'; } }); }, getSpamandSuggestions: function () { $scope.kwspam = []; $scope.kwsuggestions = []; for (var i in $scope.wordfreq) { var f = $scope.wordfreq[i]; if (f.my > f.max) { $scope.kwspam.push({ 'word': f.word, 'my': f.my, 'max': f.max }); } if (f.my < f.avg) { $scope.kwsuggestions.push({ 'word': f.word, 'my': f.my, 'avg': f.avg }); } } }, exploreCluster(cluster) { lines = []; $scope.page.states.explore = { 'open': true, 'cluster': cluster } for (var i in $scope.clusters) { for (var y in $scope.clusters[i]['lines']) { var line = $scope.clusters[i]['lines'][y]['text']; var src = $scope.clusters[i]['lines'][y]['src']; var fit = $scope.clusters[i]['lines'][y]['dna'][cluster]; if ($scope.clusters[i]['lines'][y]['text'].length > 133) { lines.push({ 'line': line, 'fit': fit, 'src': src, 'wordscore': $scope.clusters[i]['lines'][y]['wordscore'], 'score': $scope.clusters[i]['lines'][y]['score'] }); } } } $scope.page.lines = lines; }, exploreWord: function (word) { $scope.page.states.explore.open = false; var s = word.split(' '); var boldarray = []; for (var i in s) { for (var w in $scope.trans[s[i]]) { boldarray.push(w) } } suggestions = []; for (var i in $scope.clusters) { for (var y in $scope.clusters[i]['lines']) { var sent = $scope.clusters[i]['lines'][y]; if (sent.clean.indexOf(' ' + word + ' ') !== -1) { suggestions.push(sent) $scope.page.states.exploreword = { 'open': true, 'word': word, 'boldarray': boldarray, 'suggestions': suggestions } } } } } } model.getModel(); $scope.do = function (method, param) { model[method](param); } var charts = { tfchart: function () { var d = angular.copy($filter('orderBy')($scope.wordfreq, 'avg', true)); var d = d.map(function (c) { c.word = ($scope.trans[c.word]) ? Object.keys($scope.trans[c.word])[0] : c.word; return c; }) $scope.tfidfchart = { data: d, type: "serial", //autoMarginOffset: 20, marginLeft: 16, categoryField: "word", legend: { enabled: false }, chartScrollbar: { "enabled": true, "hideResizeGrips": true, "offset": 10, "scrollbarHeight": 10 }, categoryAxis: { gridPosition: "start", parseDates: false, gridThickness: 0, "labelRotation": 45 }, valueAxes: [{ "stackType": "regular", dashLength: 4, color: '#dedede', position: "top", axisAlpha: 0, labelsEnabled: false }], graphs: [{ balloonText: "AVG distribution top 10 result
[[value]]", type: "column", title: "Keyword value", valueField: "avg", fillAlphas: 1, lineColor: '#28a745', }, { balloonText: "Max distribution top 10 result
[[value]]", type: "column", title: "Keyword value", valueField: "maxminavg", fillAlphas: 1, lineColor: '#dddddd', }, { balloonText: "My distribution
[[value]]", "bullet": "round", "bulletBorderAlpha": 1, "bulletColor": "#FFFFFF", "useLineColorForBulletBorder": true, "fillAlphas": 0, "lineThickness": 2, "lineAlpha": 1, "bulletSize": 7, "valueField": "my", lineColor: '#ffc107', }], zoom: [0, 18] } }, dnaChart: function () { var dna = []; var mna = []; var ordered_clusters = $filter('orderBy')($scope.clusters, 'score', true); for (var i in ordered_clusters) { if ($scope.mna) { $scope.max_fit = (Math.max(...$scope.dna), Math.max(...$scope.mna)) dna.push({ 'cluster': ordered_clusters[i].cluster, 'dna': $scope.dna[ordered_clusters[i].cluster] * 100, 'mna': $scope.mna[ordered_clusters[i].cluster] * 100, 'i': (i * 1) + 1 }) } else { $scope.max_fit = Math.max(...$scope.dna); dna.push({ 'cluster': ordered_clusters[i].cluster, 'dna': $scope.dna[ordered_clusters[i].cluster] * 100, 'i': (i * 1) + 1 }) } } $scope.dnachart = { "type": "radar", data: dna, "theme": "none", "valueAxes": [{ "labelsEnabled": false, "axisTitleOffset": 14, "minimum": 0, "axisAlpha": 0.15 }], "startDuration": 2, "graphs": [{ "balloonText": "Cluster [[i]]
Goal: [[value]]%", "bullet": "round", "lineThickness": 2, "fillAlphas": 0.8, "valueField": "dna", lineColor: '#28a745', }, { "balloonText": "Cluster [[i]]
My page: [[value]]%", "bullet": "round", "color": "#dedede", "fillAlphas": 0.3, "lineThickness": 2, "valueField": "mna", }], "categoryField": "i", "export": { "enabled": true } }; }, } $scope.untrans = function (w) { var s = w.split(' '); for (var i in s) { s[i] = Object.keys($scope.trans[s[i]])[0]; } return s.join(' '); } } ]); app.controller('report', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$window', 'h', function($scope, $timeout, $http, $rootScope, $controller, $window, h) { // nieuw of huidig rapport $scope.page = { 'newreport': { show: false }, dates: [], kdates: {},'simpleview':true }; var d = new Date(); var thismonth = d.getMonth() + 1; var thismonth = new Date().getMonth() + 3; for (var i = 0; i < 15; i++) { var date = new Date(new Date().getFullYear(), thismonth, 0); $scope.page.dates.push({ 'date': date }); $scope.page.kdates[date.toString()] = date; thismonth = thismonth - 1; } $scope.do = function(obj, method, param) { if (obj == 'fnReport') { fnReport[method](param); } } var fnReport = { list: function() { $http.get('/rest/report/report.list', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.reports = res.data; angular.forEach(res.data, function(k, v) { var date = new Date(k.download.date * 1); k.download.date = $scope.page.kdates[date.toString()]; k.download = k.download || { status: '' } if (k.download.status == 'pending') { $timeout(function() { fnReport.list(); }, 10000); } }) }); }, 'add': function(report) { $http.post('/rest/report/report.add', angular.extend({ websiteid: h.siteId() }, report) ).then(function successCallback(res) { fnReport.list(); }); }, create: function(report) { $http.post('/rest/report/report.generate', angular.extend({ websiteid: h.siteId() }, report)) report.download.status = 'pending' fnReport.update(report); }, 'update': function(report) { report.download = report.download || {}; report.download.date = report.download.date || new Date(); report.download.date = Math.round(report.download.date.getTime() / 1000) if(report.download.enddate){ if($scope.page.simpleview == true){ delete report.download.enddate } else { console.log(report.download.enddate); report.download.enddate = report.download.enddate || new Date(); report.download.enddate = Math.round(report.download.enddate.getTime() / 1000) } } $http.post('/rest/report/report.update', angular.extend({ websiteid: h.siteId() }, report) ).then(function successCallback(res) { fnReport.list(); }); }, 'download': function(report) { console.log(report); $window.location.href = '/rest/report/report.download/?websiteid=' + h.siteId() + '&reportid=' + report.report_id; }, 'delete': function(report) { $http.post('/rest/report/report.delete', angular.extend({ websiteid: h.siteId() }, report) ).then(function successCallback(res) { fnReport.list(); }); }, } fnReport.list(); } ]); app.controller('reportitem', ['$scope', '$timeout', '$http', '$rootScope', '$location', '$anchorScroll', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $location, $anchorScroll, $filter, h) { $scope.do = function(obj, method, param) { if (obj == 'fnReport') { fnReport[method](param); } else if (obj == 'fnPage') { fnPage[method](param); } } // Search Insights en Rank tracker: dezelfde controllers als de pagina's, in rapportstand (custominit.gsci / .serp) $scope.reportcategories = [{ 'translate': 'gscinsights.title', 'types': [ { 'translate': 'gscinsights.tab.overview', 'view': 'gscinsights/report_page.html', 'init': { 'gsci': true, 'urlview': 'overview' } }, { 'translate': 'gscinsights.tab.topics', 'view': 'gscinsights/report_page.html', 'init': { 'gsci': true, 'urlview': 'topics' } }, { 'translate': 'gscinsights.tab.tracked', 'view': 'gscinsights/report_page.html', 'init': { 'gsci': true, 'urlview': 'tracked' } }, { 'translate': 'gscinsights.overview.actions_title', 'view': 'gscinsights/report_page.html', 'init': { 'gsci': true, 'urlview': 'actions' } }, { 'translate': 'menu.searchconsole.keywords', 'view': 'gscinsights/report.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'keywords' } }, { 'translate': 'menu.searchconsole.pages', 'view': 'gscinsights/report.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'pages' } }, { 'translate': 'menu.searchconsole.country', 'view': 'gscinsights/report.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'country' } }, { 'translate': 'menu.searchconsole.device', 'view': 'gscinsights/report.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'device' } } ] }, { 'translate': 'menu.serp', 'types': [ { 'translate': 'serp.tab.overview', 'view': 'serp/report.html', 'init': { 'serp': true, 'urlview': 'overview' } }, { 'translate': 'serp.tab.keywords', 'view': 'serp/report.html', 'init': { 'serp': true, 'urlview': 'keywords' } }, { 'translate': 'serp.tab.competitors', 'view': 'serp/report.html', 'init': { 'serp': true, 'urlview': 'competitors' } } ] }, /* { 'translate': 'menu.linkbuilding.linkbuilding', 'types': [ { 'translate': 'menu.linkbuilding.assistant', 'view': 'linkbuilding/assistant.html', 'init': '' }, ] },*/ { 'translate': 'menu.onpage.onpage', 'types': [ { 'translate': 'menu.onpage.overview', 'view': 'onpage/index.status.html', 'init': { 'viewrulelabel': 'all' } } ] }, { 'translate': 'menu.analytics4.analytics', 'types': [ { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.all', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'all' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.organic', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'organic' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.paid', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'paid' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.display', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'display' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.social', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'social' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.referral', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'referral' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.email', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'email' } }, { 'parent': 'menu.analytics.channels.channels', 'translate': 'menu.analytics.channels.other', 'view': 'ga4/report.html', 'init': { 'urlreport': 'channels', 'urlview': 'other' } }, ] }, { 'translate': 'menu.googleads.googleads', 'types': [ { 'translate': 'menu.googleads.all', 'view': 'api/googlexads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'all' } }, { 'translate': 'menu.googleads.adgroups', 'view': 'api/googlexads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'adgroups' } }, { 'translate': 'menu.googleads.keywords', 'view': 'api/googlexads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'keywords' } }, { 'translate': 'menu.googleads.ads', 'view': 'api/googlexads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'ads' } }, { 'translate': 'menu.googleads.conversions', 'view': 'api/googlexads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'conversions' } }, ] }, { 'translate': 'menu.facebookads.facebookads', 'types': [ { 'translate': 'menu.facebookads.all', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'all' } }, { 'translate': 'menu.facebookads.adsets', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'adsets' } }, { 'translate': 'menu.facebookads.ads', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'ads' } }, { 'translate': 'menu.facebookads.keywords', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'keywords' } }, { 'translate': 'menu.facebookads.age', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'age' } }, { 'translate': 'menu.facebookads.gender', 'view': 'api/facebookxads.html', 'init': { 'urlreport': 'campaigns', 'urlview': 'gender' } }, ] }, { 'translate': 'menu.facebookinsights.facebookinsights', 'types': [ { 'translate': 'menu.facebookinsights.fans', 'view': 'api/facebookinsights.html', 'init': { 'urlreport': 'page', 'urlview': 'fans' } }, { 'translate': 'menu.facebookinsights.engagements', 'view': 'api/facebookinsights.html', 'init': { 'urlreport': 'page', 'urlview': 'engagements' } }, { 'translate': 'menu.facebookinsights.reach', 'view': 'api/facebookinsights.html', 'init': { 'urlreport': 'page', 'urlview': 'reach' } }, { 'translate': 'menu.facebookinsights.posts', 'view': 'api/facebookinsights.html', 'init': { 'urlreport': 'page', 'urlview': 'posts' } } ] }, { 'translate': 'menu.mybusiness.mybusiness', 'types': [ { 'translate': 'menu.mybusiness.all', 'view': 'api/mybusiness.html', 'init': { 'urlreport': 'business', 'urlview': 'all' } }, { 'translate': 'menu.mybusiness.reviews', 'view': 'api/mybusiness.html', 'init': { 'urlreport': 'business', 'urlview': 'reviews' } }, ] }, { 'translate': 'menu.planning', 'types': [ { 'translate': 'menu.planning', 'view': 'planning/index.html', 'init': '' }, ] }, { 'translate': 'menu.linkbuilding', 'types': [ { 'translate': 'menu.linkbuilding', 'view': 'linkbuilding/report.html', 'init': { 'linkbuilding': true } }, ] }, ]; var fnPage = { 'new': function() { return {}; }, 'type': function(o) { o.page.type = false; $timeout(function() { o.page.type = o.type; o.page.changetype = false; fnReport.update(); }, 500); } } var fnReport = { get: function() { $http.get('/rest/report/report.get', { params: { websiteid: h.siteId(), report_id: $scope.report_id, } }).then(function successCallback(res) { console.log('ORIG DATE TS', res.data.download.date) // Handle both seconds and milliseconds timestamps var dateTs = res.data.download.date * 1; if (dateTs < 10000000000) dateTs = dateTs * 1000; // Convert seconds to milliseconds var date = new Date(dateTs); if(res.data.download.enddate){ console.log('END DATE TS', res.data.download.enddate) // Handle both seconds and milliseconds timestamps var endDateTs = res.data.download.enddate * 1; if (endDateTs < 10000000000) endDateTs = endDateTs * 1000; var enddate = new Date(endDateTs); var endts = new Date(enddate.getFullYear(), enddate.getMonth(), 0).getTime() / 1000; } else { var endts = new Date(date.getFullYear(), date.getMonth() + 1, 0).getTime() / 1000; } res.data.download.date = date; $rootScope.reportdates = { /* api */ 'end': endts, 'start': new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000, /* planning */ 'datestring': date.toLocaleString('default', { year: 'numeric', 'month': 'long' }) } console.log('REPORTDATES', $rootScope.reportdates); $scope.report = res.data; }); }, getsettings: function() { $http.get('/rest/report/settings.get', { params: { websiteid: h.siteId(), report_id: $scope.report_id, } }).then(function successCallback(res) { $scope.settings = res.data; }); }, 'addpage': function(index) { console.log(index, 'is index'); $scope.report.pages = $scope.report.pages || []; $scope.report.pages.splice(index, 0, fnPage.new()); $timeout(function() { angular.element('html, body').animate({ scrollTop: angular.element('#page' + index).offset().top - 120 }, 400); }, 200); }, 'deletepage': function(index) { $scope.report.pages = $scope.report.pages || []; $scope.report.pages.splice(index, 1); fnReport.update(); }, 'update': function(report) { report = $scope.report; report.download = report.download || {}; report.download.date = report.download.date || new Date(); report.download.date = Math.round(report.download.date.getTime() / 1000) $http.post('/rest/report/report.update', angular.extend({ websiteid: h.siteId() }, $scope.report) ).then(function successCallback(res) { fnReport.get(); }); }, } fnReport.get(); fnReport.getsettings(); } ]); app.controller('reportpage', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$window', 'h', function($scope, $timeout, $http, $rootScope, $controller, $window, h) { } ]); app.controller('coreapi', ['$scope', '$timeout', '$http', '$rootScope', '$filter', '$location', 'h', function ($scope, $timeout, $http, $rootScope, $filter, $location, h) { $scope.charts = {}; $scope.progresschart = {}; $scope.viewfilters = {}; var colorindex = 0; $scope.dateoptions = [ { start: 'TODAY', end: 'TODAY', description: 'Today' }, { start: 'YESTYERDAY', end: 'YESTYERDAY', description: 'Yesterday' }, { start: '7DAYSAGO', end: 'TODAY', description: 'Past 7 days' }, { start: '14DAYSAGO', end: 'TODAY', description: 'Past 14 days' }, { start: '30DAYSAGO', end: 'TODAY', description: 'Past 30 days' }, { start: '1YEARAGO', end: 'TODAY', description: 'Past year' }, { start: 'THISMONTH', end: 'TODAY', description: 'This month' }, { start: 'LASTMONTH', end: 'TODAY', description: 'Previous month' }, ] $scope.compareoptions = [ { 'compare': false, 'comparedescription': 'Do not compare' }, { 'compare': 'PERIOD', 'comparedescription': 'Compare with previous period' }, { 'compare': 'YEAR', 'comparedescription': 'Compare with previous year' }, ] $scope.fnCustomDate = { 'init': function () { var d = new Date() d.setMonth(d.getMonth() - 1); $scope.customdate = { 'start': d, 'end': new Date(), 'show': false } }, 'set': function () { console.log('------------------- AHHHHH '); $scope.customdate.show = false; $rootScope.reportdates.start = Math.round($scope.customdate.start.getTime() / 1000) $rootScope.reportdates.end = Math.round($scope.customdate.end.getTime() / 1000) $rootScope.reportdates.description = 'Custom'; getReportData(); } } $scope.fnCustomDate.init(); $scope.table = { 'curpage': 0, 'itemsperpage': 20, 'offset': 0, fnSetOffset: function () { this.offset = this.itemsperpage * (this.curpage - 1); } } if (!$rootScope.reportdates) { $rootScope.reportdates = { 'start': '30DAYSAGO', 'end': 'TODAY', 'description': 'Past 30 days', 'compare': false, 'comparedescription': 'Do not compare' }; } $scope.fnDateSelection = function (reportdates) { $rootScope.reportdates.start = reportdates.start; $rootScope.reportdates.end = reportdates.end; $rootScope.reportdates.description = reportdates.description; getReportData(); } $scope.fnCompareSelection = function (reportdates) { $rootScope.reportdates.compare = reportdates.compare; $rootScope.reportdates.comparedescription = reportdates.comparedescription; getReportData(); } // iedereen met toegang tot de site mag koppelen en kiezen, ook een gast (Arjen 2026-11-03; siteConnections::canManage) $scope.getCustomer = function () { $http.get('/rest/' + ($scope.cfg.restbase || $scope.cfg.urlbase) + '/getcustomer', { params: { action: 'getproperties', websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.customers = res.data; $scope.fnModal('api/modal/' + $scope.cfg.urlbase + '.selectcustomer'); }); } $scope.setCustomer = function (data) { $http.get('/rest/' + ($scope.cfg.restbase || $scope.cfg.urlbase) + '/setcustomer', { params: { websiteid: h.siteId(), customer: data } }).then(function successCallback(res) { getReportData(); }); } $scope.useApiGrant = function (apigrant) { $http.get('/rest/api-core/apigrants.set', { params: { websiteid: h.siteId(), 'type': $scope.cfg.urlbase, 'grantid': apigrant.grantid, } }).then(function successCallback(res) { getReportData(); }); } $scope.fnOptions = function () { if ($scope.view.viewfilters) { $http.get('/rest/' + $scope.cfg.urlbase + '/getviewfilters', { params: { websiteid: h.siteId(), viewfilters: $scope.view.viewfilters.join(',') } }).then(function successCallback(res) { $scope.viewfilteroptions = res.data.data }); } if ($scope.cfg.switchclient) { $http.get('/rest/' + ($scope.cfg.restbase || $scope.cfg.urlbase) + '/getcustomer', { params: { action: 'getproperties', websiteid: h.siteId(), } }).then(function successCallback(res) { $scope.customers = res.data; }); } $scope.fnModal('api/modal/' + $scope.cfg.urlbase + '.options'); } $scope.pullCustomer = function () { $http.get('/rest/' + ($scope.cfg.restbase || $scope.cfg.urlbase) + '/pullcustomer', { params: { websiteid: h.siteId() } }).then(function successCallback(res) { getReportData(); }); } var _fnFetchData = function (i) { if ($scope.view[i]) { var vv = {}; var metrics = ($scope.view[i].metrics) ? $scope.view[i].metrics : $scope.metrics; if ($scope.viewfilters) { for (var y in $scope.viewfilters) { vv[y] = $scope.viewfilters[y]['viewfilter']; } } // een view kan een eigen url hebben (de oude Search Console-subrapporten, tot die pagina weg is) $http.get($scope.view[i].url || $scope.cfg.report_endpoint, { params: { websiteid: h.siteId(), metrics: metrics.join(','), dimension: $scope.view[i].dimension, dimension2: $scope.view[i].dimension2, report: $scope.view[i].report, channel: $scope.view[i].channel, context: $scope.view[i].context, viewfilters: vv, compare: $scope.view[i].compare || $rootScope.reportdates.compare, allowcomp: $scope.view[i].allowcomp, level: $scope.view[i].level, endpoint: $scope.view[i].endpoint, summary: $scope.view[i].summary, breakdowns: $scope.view[i].breakdowns, filterdimension: $scope.view[i].filterdimension, filterexpression: $scope.view[i].filterexpression, filteroperator: $scope.view[i].filteroperator, sort: $scope.view[i].sort, sortorder: $scope.view[i].sortorder, start: ($scope.view[i].start) ? $scope.view[i].start : $rootScope.reportdates.start, end: ($scope.view[i].end) ? $scope.view[i].end : $rootScope.reportdates.end, limit: $scope.view[i].limit, status: $scope.view[i].status, campaignid: $scope.view[i].campaignid, filter: $scope.view[i].filter, campaign: $scope.view[i].campaign, adgroup: $scope.view[i].adgroup, network: $scope.view[i].network, zeroimpressions: $scope.view[i].zeroimpressions, } }).then(function successCallback(res) { if ($scope.view[i].callback) { $scope.view[i].callback(res.data); } if (res.data.error) { $scope.fnModal('api/modal/global.norights'); } else if (res.data.action == 'connect') { $scope.fnModal('api/modal/' + $scope.cfg.urlbase + '.connect'); $http.get('/rest/api-core/apigrants.get', { params: { websiteid: h.siteId(), 'type': $scope.cfg.urlbase, } }).then(function successCallback(res) { if (res.data.data !== null) { $scope.apigrants = res.data.data; } }); if (res.data.sets) { for (var set in res.data.sets) { $scope.analytics[set] = res.data.sets[set]; for (var y in $scope.charts) { if ($scope.charts[y].dataset == set) { fnMakeChart($scope.charts[y]); } } } $scope.analytics[i] = { totals: res.data.totals }; } else { $scope.analytics[i] = res.data; for (var y in $scope.charts) { if ($scope.charts[y].dataset == i) { fnMakeChart($scope.charts[y]); } } } } else if (res.data.action == 'getcustomer') { $scope.getCustomer(); } else if (res.data.sets) { for (var set in res.data.sets) { $scope.analytics[set] = res.data.sets[set]; for (var y in $scope.charts) { if ($scope.charts[y].dataset == set) { fnMakeChart($scope.charts[y]); } } } $scope.analytics[i] = { totals: res.data.totals }; } else { $scope.analytics[i] = res.data; for (var y in $scope.charts) { if ($scope.charts[y].dataset == i) { fnMakeChart($scope.charts[y]); } } } }, function errorCallback() { $scope.fnModal('api/modal/global.error'); }); } } var getReportData = function () { $scope.fnCloseModal(); $scope.analytics = {}; $scope.pagecharts = {}; _fnFetchData('first'); _fnFetchData('second'); _fnFetchData('third'); _fnFetchData('fourth'); } var fnMakeChart = function (chart) { console.log(chart.dataset); var dataset = $scope.analytics[chart.dataset].data; var dimension = (chart.dimension) ? chart.dimension : ($scope.cfg.dimensionprefix + $scope.view[chart.dataset].dimension); var metric = (chart.metric) ? chart.metric : $scope.metric; $scope.progresschart[chart.chart] = false; if (chart['type'] == 'line') { _charts.fnLineChart(chart, dataset, dimension, metric); } else if (chart['type'] == 'bar') { _charts.fnBarChart(chart, dataset, dimension, metric); } else if (chart['type'] == 'pie') { _charts.fnPieChart(chart, dataset, dimension, metric); } else if (chart['type'] == 'linemulti') { _charts.fnLineChartMultiple(chart, dataset, dimension, metric); } else if (chart['type'] == 'bubble') { _charts.fnBubbleChart(chart, dataset, dimension, metric); } else if (chart['type'] == 'progress') { _charts.fnProgressChart(chart, dataset, dimension, metric); } } $scope.colors = ['#E91E63', '#3F51B5', '#00BCD4', '#8BC34A', '#FF9800', '#795548', '#607D8B', '#00CC00', '#0000CC', '#DDDDDD', '#999999', '#333333', '#990000'] var _charts = { fnBubbleChart: function (chart, dataset, dimension, metric) { return false; }, fnProgressChart: function (chart, dataset, dimension, metric) { $scope.pagecharts[chart.chart] = false var b = $filter('orderBy')(dataset, metric, $scope.order.reverse).slice(0, 5); $scope.progresschart[chart.chart] = b; }, fnLineChart: function (chart, dataset, dimension, metric) { $scope.gloMetrics[metric] = $scope.gloMetrics[metric] || {}; var datamerge = $scope.gloMetrics[metric].datemerge; var datesubstr = ($scope.cfg.dateformat == 'YYYYMMDD') ? [0, 4, 4, 6, 6, 8] : [0, 4, 5, 7, 8, 10]; var data = {}; // if (dataset.length > 62 && datamerge) { // for (var i in dataset) { // // todo date format YYYY-MM-DD vs YYYYMMDD // var year = dataset[i][dimension].toString().substring(datesubstr[0], datesubstr[1]); // var month = dataset[i][dimension].toString().substring(datesubstr[2], datesubstr[3]); // var day = dataset[i][dimension].toString().substring(datesubstr[4], datesubstr[5]); // var date = new Date(year, month - 1, day); // onejan = new Date(date.getFullYear(), 0, 1); // week = Math.ceil((((date - onejan) / 86400000) + onejan.getDay() + 1) / 7); // var yw = year + '_' + week; // if (data[yw]) { // data[yw][metric] += dataset[i][metric]; // data[yw]['n'] += 1; // } else { // data[yw] = dataset[i]; // data[yw]['n'] = 1; // } // } // // todo calc average // dataset = $filter('orderBy')(Object.values(data), 'dimension', false); // if (datamerge == 'avg') { // dataset = dataset.map(function (c) { // c[metric] = c[metric] / c['n']; // return c; // }) // } // } // A new function to merge data into 31-day buckets, starting from the last entry. if (dataset.length > 62 && datamerge) { // Condition changed to 31 for one bucket period. // Assuming the dataset is sorted by date, with the most recent date at the end. // If not sorted, you would need to find the maximum date first. const lastEntry = dataset[dataset.length - 1]; const endDateYear = lastEntry[dimension].toString().substring(datesubstr[0], datesubstr[1]); const endDateMonth = lastEntry[dimension].toString().substring(datesubstr[2], datesubstr[3]); const endDateDay = lastEntry[dimension].toString().substring(datesubstr[4], datesubstr[5]); // Set the anchor date to the end of the day of the last entry. const anchorDate = new Date(endDateYear, endDateMonth - 1, endDateDay); anchorDate.setHours(23, 59, 59, 999); // Use end of day to ensure the last day is included. const data = {}; const MS_PER_DAY = 86400000; // Milliseconds in one day (24 * 60 * 60 * 1000) let period = 7; // Loop backwards from the end of the dataset. for (let i = dataset.length - 1; i >= 0; i--) { const item = dataset[i]; // Parse the date for the current item. const year = item[dimension].toString().substring(datesubstr[0], datesubstr[1]); const month = item[dimension].toString().substring(datesubstr[2], datesubstr[3]); const day = item[dimension].toString().substring(datesubstr[4], datesubstr[5]); const currentDate = new Date(year, month - 1, day); // Calculate how many days ago this date was from our anchor date. const daysDifference = (anchorDate.getTime() - currentDate.getTime()) / MS_PER_DAY; // Determine the bucket index. Each bucket is a 31-day period. // Math.floor(0 / 31) = 0, Math.floor(30.9 / 31) = 0, Math.floor(31 / 31) = 1 const bucketIndex = Math.floor(daysDifference / period); // Create a representative date for the bucket's start day for a consistent key. const bucketStartDate = new Date(anchorDate); bucketStartDate.setDate(anchorDate.getDate() - (bucketIndex + 1) * period + 1); // Format the bucket key to match the API's date format. const bYear = bucketStartDate.getFullYear(); const bMonth = ('0' + (bucketStartDate.getMonth() + 1)).slice(-2); const bDay = ('0' + bucketStartDate.getDate()).slice(-2); const bucketKey = ($scope.cfg.dateformat === 'YYYYMMDD') ? '' + bYear + bMonth + bDay : bYear + '-' + bMonth + '-' + bDay; // Aggregate data into the bucket. if (data[bucketKey]) { data[bucketKey][metric] += item[metric]; data[bucketKey]['n'] += 1; } else { // Create a new entry for the bucket. data[bucketKey] = { ...item }; // Copy item to avoid modifying original data[bucketKey][dimension] = bucketKey; // Set the dimension to our bucket's start date data[bucketKey]['n'] = 1; } } // Convert the aggregated data object back to an array and sort it. dataset = $filter('orderBy')(Object.values(data), dimension, false); // If 'avg' is specified, calculate the average for each bucket. if (datamerge === 'avg') { dataset = dataset.map(function (c) { c[metric] = c[metric] / c['n']; return c; }); } } $scope.pagecharts[chart.chart] = { data: dataset, type: 'serial', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, fontFamily: 'Roboto', categoryField: dimension, rotate: false, pathToImages: 'vendor/amchart/images/', dataDateFormat: $scope.cfg.dateformat, categoryAxis: { gridPosition: "start", parseDates: true, gridThickness: 0 }, valueAxes: [{ position: "left", axisAlpha: 0, labelsEnabled: !chart.hideAxisLabels, dashLength: 4, color: '#dedede', }], graphs: [{ balloonText: $filter('translate')('metrics.' + metric) + "
[[value]]", "bullet": "round", "bulletBorderAlpha": 1, "bulletColor": "#FFFFFF", "useLineColorForBulletBorder": true, "fillAlphas": 0.2, "lineThickness": 2, "lineAlpha": 1, "bulletSize": 7, "connect": true, "type": "smoothedLine", "title": "Expenses", "valueField": metric, lineColor: $scope.colors[colorindex], }], } colorindex += 1; }, fnLineChartMultiple: function (chart, dataset, dimension, metric) { var d = []; var dimension2 = (chart.dimension2) ? chart.dimension2 : $scope.cfg.dimensionprefix + $scope.view[chart.dataset].dimension2; var datamerge = ($scope.gloMetrics[metric] || {}).datemerge; if (dataset && dataset.length > 62 && datamerge) { var datesubstr = ($scope.cfg.dateformat == 'YYYYMMDD') ? [0, 4, 4, 6, 6, 8] : [0, 4, 5, 7, 8, 10]; var lastEntry = dataset[dataset.length - 1]; if (lastEntry) { var lastYear = lastEntry[dimension].toString().substring(datesubstr[0], datesubstr[1]); var lastMonth = lastEntry[dimension].toString().substring(datesubstr[2], datesubstr[3]); var lastDay = lastEntry[dimension].toString().substring(datesubstr[4], datesubstr[5]); var anchorDate = new Date(lastYear, lastMonth - 1, lastDay); anchorDate.setHours(23, 59, 59, 999); var MS_PER_DAY = 86400000; var period = 7; var bucketMap = {}; var formatBucketKey = function (dateObj) { var year = dateObj.getFullYear(); var month = ('0' + (dateObj.getMonth() + 1)).slice(-2); var day = ('0' + dateObj.getDate()).slice(-2); if ($scope.cfg.dateformat === 'YYYYMMDD') { return '' + year + month + day; } return year + '-' + month + '-' + day; }; for (var idx = dataset.length - 1; idx >= 0; idx--) { var row = dataset[idx]; var keyYear = row[dimension].toString().substring(datesubstr[0], datesubstr[1]); var keyMonth = row[dimension].toString().substring(datesubstr[2], datesubstr[3]); var keyDay = row[dimension].toString().substring(datesubstr[4], datesubstr[5]); var currentDate = new Date(keyYear, keyMonth - 1, keyDay); var daysDifference = (anchorDate.getTime() - currentDate.getTime()) / MS_PER_DAY; var bucketIndex = Math.floor(daysDifference / period); var bucketStart = new Date(anchorDate); bucketStart.setDate(anchorDate.getDate() - (bucketIndex + 1) * period + 1); var bucketKey = formatBucketKey(bucketStart); var segment = row[dimension2]; if (!segment) { continue; } bucketMap[bucketKey] = bucketMap[bucketKey] || {}; bucketMap[bucketKey][segment] = bucketMap[bucketKey][segment] || { sum: 0, count: 0 }; bucketMap[bucketKey][segment].sum += parseFloat(row[metric]) || 0; bucketMap[bucketKey][segment].count += 1; } var mergedDataset = []; var bucketKeys = Object.keys(bucketMap).sort(); for (var kIndex = 0; kIndex < bucketKeys.length; kIndex++) { var bucketKey = bucketKeys[kIndex]; var segments = bucketMap[bucketKey]; for (var segmentName in segments) { if (!segments.hasOwnProperty(segmentName)) { continue; } var entry = segments[segmentName]; var value = (datamerge === 'avg' && entry.count > 0) ? entry.sum / entry.count : entry.sum; if (!isFinite(value)) { value = 0; } var mergedRow = {}; mergedRow[dimension] = bucketKey; mergedRow[dimension2] = segmentName; mergedRow[metric] = value; mergedDataset.push(mergedRow); } } dataset = mergedDataset; } } if (!chart.graphs || !chart.graphs.length) { var totalsByDimension2 = {}; dataset.forEach(function (row) { var groupKey = row[dimension2]; if (!groupKey) { return; } var value = parseFloat(row[metric]); if (isNaN(value)) { return; } totalsByDimension2[groupKey] = (totalsByDimension2[groupKey] || 0) + value; }); chart.graphs = Object.keys(totalsByDimension2) .map(function (key) { return { name: key, total: totalsByDimension2[key] }; }) .sort(function (a, b) { return b.total - a.total; }) .slice(0, 5) .map(function (item) { return item.name; }); } for (var i in dataset) { var row = dataset[i]; d[row[dimension]] = d[row[dimension]] || {}; d[row[dimension]].c = row[dimension]; if (chart.graphs) { for (var y in chart.graphs) { if (chart.graphs[y] == row[dimension2]) { d[row[dimension]]['t' + chart.graphs[y]] = row[metric]; } } } } console.log('chart', chart); var b = []; Object.keys(d).sort().forEach(function (key) { b.push(d[key]); }); var graphs = []; for (var y in chart.graphs) { // *** FIX: Changed 'var' to 'let' to correctly scope the variable for the closure *** let segmentName = chart.graphs[y]; graphs.push({ balloonText: chart.graphs[y] + '
' + $filter('translate')('metrics.' + metric) + " [[value]]", "bullet": "round", "bulletBorderAlpha": 1, "bulletColor": "#FFFFFF", "useLineColorForBulletBorder": true, "fillAlphas": 0, "lineThickness": 2, "lineAlpha": 1, "bulletSize": 7, "title": "Expenses", "valueField": 't' + chart.graphs[y], lineColor: $scope.colors[y], stacked: false, balloonFunction: function (item) { var dateLabel = $filter('date')(item.category, 'yyyy-MM-dd'); var rawValue = item.values.value; var formatted = $filter('useFilter')( rawValue, $scope.gloMetrics[metric].typefilter, $scope.gloMetrics[metric].filtercomparator ); var suffix = $scope.gloMetrics[metric].after || ''; return ( '
' + '
' + segmentName + '
' + // This will now use the correct segmentName '
' + dateLabel + '
' + '
' + $filter('translate')('metrics.' + metric) + ': ' + formatted + suffix + '' + '
' + '
' ); } }); if (chart.graphs[y] == 'male') { graphs[y].lineColor = '#3F51B5'; } if (chart.graphs[y] == 'female') { graphs[y].lineColor = '#E91E63'; } } $scope.pagecharts[chart.chart] = { data: b, type: 'serial', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, fontFamily: 'Roboto', categoryField: 'c', rotate: false, pathToImages: 'vendor/amchart/images/', dataDateFormat: $scope.cfg.dateformat, legend: { enabled: false }, categoryAxis: { gridPosition: "start", parseDates: true, labelRotation: 45 }, valueAxes: [{ position: "left", axisAlpha: 0, labelsEnabled: true, }], graphs: graphs, }; if (chart.stacked) { $scope.pagecharts[chart.chart].valueAxes[0].stackType = "regular"; } }, fnBarChart: function (chart, dataset, dimension, metric) { //inject color attribute with value if (typeof $scope.analytics[chart.dataset].data != undefined) { for (var i = 0; i < $scope.analytics[chart.dataset].data.length; i++) { $scope.analytics[chart.dataset].data[i].color = $scope.colors[i]; } } $scope.pagecharts[chart.chart] = { data: dataset.slice(0, 6), type: 'serial', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, fontFamily: 'Roboto', categoryField: dimension, rotate: false, pathToImages: 'vendor/amchart/images/', dataDateFormat: $scope.cfg.dateformat, legend: { enabled: false }, categoryAxis: { gridPosition: "start", parseDates: false, labelRotation: 0, gridThickness: 0, labelFunction: function (value, valueText, axis) { return (value.length > 19) ? value.substr(0, 17) + '...' : value }, }, valueAxes: [{ position: "left", axisAlpha: 0, labelsEnabled: true, dashLength: 4, color: '#dedede', }], graphs: [{ fillColorsField: "color", balloonText: "[[category]]: [[value]]", type: "column", columnWidth: 0.40, title: "Zoekwoord waarde", valueField: metric, fillAlphas: 1, lineColor: '#009688', autoColor: true, }], } }, fnPieChart: function (chart, dataset, dimension, metric) { $scope.pagecharts[chart.chart] = { data: dataset.slice(0, 9), type: 'pie', theme: 'light', marginLeft: 0, marginRight: 0, marginTop: 10, fontFamily: 'Roboto', categoryField: dimension, pathToImages: 'vendor/amchart/images/', "precision": 0, "decimalSeparator": ",", "thousandsSeparator": ".", legend: { enabled: true, position: 'right', autoMargins: false, marginRight: 100, }, titleField: dimension, valueField: metric, labelRadius: 5, radius: '42%', innerRadius: '60%', labelText: '[[title]]', } } } $scope.fnAddChart = function (name, functionname) { _charts[name] = functionname; } // Grafieken voor een metric. Heeft de view er geen eigen grafiek voor (veel views noemen alleen een paar // metrics in view.charts), dan dezelfde grafieken als de standaardmetric van de view, met de naam van deze // metric; een taart wordt een staaf bij een gemiddelde of percentage. var fnChartsFor = function (metric) { if (!$scope.view || !$scope.view.charts) { return {}; } if ($scope.view.charts[metric]) { return $scope.view.charts[metric]; } var base = $scope.view.charts[$scope.view.selectedmetric] || $scope.view.charts[Object.keys($scope.view.charts)[0]]; if (!Array.isArray(base)) { return {}; } var label = $filter('translate')('metrics.' + metric); if (label === 'metrics.' + metric) { label = (($scope.gloMetrics || {})[metric] || {}).name || metric; } var avg = ((($scope.gloMetrics || {})[metric]) || {}).datemerge === 'avg'; $scope.view.charts[metric] = base.map(function (chart) { var copy = angular.extend({}, chart, { name: chart.name === '_' ? '_' : label, type: (chart.type === 'pie' && avg) ? 'bar' : chart.type }); delete copy.metric; return copy; }); return $scope.view.charts[metric]; } $scope.fnSwitchMetric = function (selectedmetric) { $scope.metric = selectedmetric; $scope.charts = fnChartsFor($scope.metric); if ($scope.view && $scope.view.fullchart && Array.isArray($scope.charts)) { var hasActive = false; for (var idx = 0; idx < $scope.charts.length; idx++) { if ($scope.charts[idx].active) { hasActive = true; break; } } if (!hasActive && $scope.charts.length) { $scope.charts[0].active = true; } } var reverse = ($scope.gloMetrics[selectedmetric].reverse === true || $scope.gloMetrics[selectedmetric].reverse === false) ? $scope.gloMetrics[selectedmetric].reverse : true; $scope.order = { order: selectedmetric, 'reverse': reverse }; for (var i in $scope.charts) { fnMakeChart($scope.charts[i]); } } $scope.fnActivateChart = function (chart) { if (!$scope.charts || !chart) { return; } angular.forEach($scope.charts, function (item) { item.active = false; }); chart.active = true; fnMakeChart(chart); } $scope.fnConnect = function () { window.location.replace($scope.cfg.authurl + '&h=' + encodeURIComponent('/' + $scope.cfg.urlbase + '/' + $scope.urlreport + '/' + $scope.urlview)); } $scope.getReportData = function () { getReportData(); } $scope.fnMakeChart = function (chart) { fnMakeChart(chart); } $scope.fnPctChange = function (o, n) { if (n == 0) { return '0%'; } var pct = ((n - o) / o) * 100; var npct = $filter('number')(pct, 2); if (pct < 0) { return '' + npct + '%'; } return '+ ' + npct + '%'; } $scope.fnSaveSelectedMetrics = function () { $http.post('/rest/api-core/selectedmetrics.set', { websiteid: h.siteId(), 'type': $scope.cfg.urlbase, 'view': $scope.urlreport + '-' + $scope.urlview, 'selectedmetrics': $scope.selectedmetrics, }); } $scope.fnRemoveViewfilter = function (type) { delete $scope.viewfilters[type]; getReportData(); } $scope.init = function () { if ($scope.custominit) { $scope.urlreport = $scope.custominit.urlreport; $scope.urlview = $scope.custominit.urlview; } /* set selection of view metric and chart*/ $scope.views = $scope.reportconfig[$scope.urlreport].views; $scope.view = $scope.reportconfig[$scope.urlreport].views[$scope.urlview]; /* selected wmetrics ophalen ...... aparte functie ... ?*/ $scope.selectedmetrics = $scope.view.selectedmetrics; $http.get('/rest/api-core/selectedmetrics.get', { params: { websiteid: h.siteId(), 'type': $scope.cfg.urlbase, 'view': $scope.urlreport + '-' + $scope.urlview, } }).then(function successCallback(res) { if (res.data.data !== null) { $scope.selectedmetrics = res.data.data; } }); $scope.metrics = $scope.view.metrics; $scope.metric = $scope.view.selectedmetric; // een link vanaf het dashboard (?metric=sessions) opent de pagina op die metriek; alleen bij het openen var asked = $location.search().metric; if (asked && ($scope.view.metrics || []).indexOf(asked) > -1) { $scope.metric = asked; } $scope.order = { order: $scope.metric, reverse: true }; $scope.charts = fnChartsFor($scope.metric); getReportData(); } var l = { "ab": "Abkhaz", "aa": "Afar", "af": "Afrikaans", "ak": "Akan", "sq": "Albanian", "am": "Amharic", "ar": "Arabic", "an": "Aragonese", "hy": "Armenian", "as": "Assamese", "av": "Avaric", "ae": "Avestan", "ay": "Aymara", "az": "Azerbaijani", "bm": "Bambara", "ba": "Bashkir", "eu": "Basque", "be": "Belarusian", "bn": "Bengali; Bangla", "bh": "Bihari", "bi": "Bislama", "bs": "Bosnian", "br": "Breton", "bg": "Bulgarian", "my": "Burmese", "ca": "Catalan; Valencian", "ch": "Chamorro", "ce": "Chechen", "ny": "Chichewa; Chewa; Nyanja", "zh": "Chinese", "cv": "Chuvash", "kw": "Cornish", "co": "Corsican", "cr": "Cree", "hr": "Croatian", "cs": "Czech", "da": "Danish", "dv": "Divehi; Dhivehi; Maldivian;", "nl": "Nederlands", "dz": "Dzongkha", "en": "English", "eo": "Esperanto", "et": "Estonian", "ee": "Ewe", "fo": "Faroese", "fj": "Fijian", "fi": "Finnish", "fr": "French", "ff": "Fula; Fulah; Pulaar; Pular", "gl": "Galician", "ka": "Georgian", "de": "German", "el": "Greek, Modern", "gn": "Guaraní", "gu": "Gujarati", "ht": "Haitian; Haitian Creole", "ha": "Hausa", "he": "Hebrew (modern)", "hz": "Herero", "hi": "Hindi", "ho": "Hiri Motu", "hu": "Hungarian", "ia": "Interlingua", "id": "Indonesian", "ie": "Interlingue", "ga": "Irish", "ig": "Igbo", "ik": "Inupiaq", "io": "Ido", "is": "Icelandic", "it": "Italian", "iu": "Inuktitut", "ja": "Japanese", "jv": "Javanese", "kl": "Kalaallisut, Greenlandic", "kn": "Kannada", "kr": "Kanuri", "ks": "Kashmiri", "kk": "Kazakh", "km": "Khmer", "ki": "Kikuyu, Gikuyu", "rw": "Kinyarwanda", "ky": "Kyrgyz", "kv": "Komi", "kg": "Kongo", "ko": "Korean", "ku": "Kurdish", "kj": "Kwanyama, Kuanyama", "la": "Latin", "lb": "Luxembourgish, Letzeburgesch", "lg": "Ganda", "li": "Limburgish, Limburgan, Limburger", "ln": "Lingala", "lo": "Lao", "lt": "Lithuanian", "lu": "Luba-Katanga", "lv": "Latvian", "gv": "Manx", "mk": "Macedonian", "mg": "Malagasy", "ms": "Malay", "ml": "Malayalam", "mt": "Maltese", "mi": "MÄori", "mr": "Marathi (MarÄá¹­hÄ«)", "mh": "Marshallese", "mn": "Mongolian", "na": "Nauru", "nv": "Navajo, Navaho", "nb": "Norwegian BokmÃ¥l", "nd": "North Ndebele", "ne": "Nepali", "ng": "Ndonga", "nn": "Norwegian Nynorsk", "no": "Norwegian", "ii": "Nuosu", "nr": "South Ndebele", "oc": "Occitan", "oj": "Ojibwe, Ojibwa", "cu": "Old Church Slavonic, Church Slavic, Church Slavonic, Old Bulgarian, Old Slavonic", "om": "Oromo", "or": "Oriya", "os": "Ossetian, Ossetic", "pa": "Panjabi, Punjabi", "pi": "PÄli", "fa": "Persian (Farsi)", "pl": "Polish", "ps": "Pashto, Pushto", "pt": "Portuguese", "qu": "Quechua", "rm": "Romansh", "rn": "Kirundi", "ro": "Romanian", "ru": "Russian", "sa": "Sanskrit (Saá¹ská¹›ta)", "sc": "Sardinian", "sd": "Sindhi", "se": "Northern Sami", "sm": "Samoan", "sg": "Sango", "sr": "Serbian", "gd": "Scottish Gaelic; Gaelic", "sn": "Shona", "si": "Sinhala, Sinhalese", "sk": "Slovak", "sl": "Slovene", "so": "Somali", "st": "Southern Sotho", "az": "South Azerbaijani", "es": "Spanish; Castilian", "su": "Sundanese", "sw": "Swahili", "ss": "Swati", "sv": "Swedish", "ta": "Tamil", "te": "Telugu", "tg": "Tajik", "th": "Thai", "ti": "Tigrinya", "bo": "Tibetan Standard, Tibetan, Central", "tk": "Turkmen", "tl": "Tagalog", "tn": "Tswana", "to": "Tonga (Tonga Islands)", "tr": "Turkish", "ts": "Tsonga", "tt": "Tatar", "tw": "Twi", "ty": "Tahitian", "ug": "Uyghur, Uighur", "uk": "Ukrainian", "ur": "Urdu", "uz": "Uzbek", "ve": "Venda", "vi": "Vietnamese", "vo": "Volapük", "wa": "Walloon", "cy": "Welsh", "wo": "Wolof", "fy": "Western Frisian", "xh": "Xhosa", "yi": "Yiddish", "yo": "Yoruba", "za": "Zhuang, Chuang", "zu": "Zulu" }; var c = { "AF": "Afghanistan", "AX": "Åland", "AL": "Albanië", "DZ": "Algerije", "VI": "Amerikaanse Maagdeneilanden", "AS": "Amerikaans-Samoa", "AD": "Andorra", "AO": "Angola", "AI": "Anguilla", "AQ": "Antarctica", "AG": "Antigua en Barbuda", "AR": "Argentinië", "AM": "Armenië", "AW": "Aruba", "AU": "Australië", "AZ": "Azerbeidzjan", "BS": "Bahama's", "BH": "Bahrein", "BD": "Bangladesh", "BB": "Barbados", "BE": "België", "BZ": "Belize", "BJ": "Benin", "BM": "Bermuda", "BT": "Bhutan", "BO": "Bolivia", "BQ": "Bonaire, Sint Eustatius en Saba", "BA": "Bosnië en Herzegovina", "BW": "Botswana", "BV": "Bouvet", "BR": "Brazilië", "VG": "Britse Maagdeneilanden", "IO": "Brits Indische Oceaanterritorium", "BN": "Brunei", "BG": "Bulgarije", "BF": "Burkina Faso", "BI": "Burundi", "KH": "Cambodja", "CA": "Canada", "CF": "Centraal-Afrikaanse Republiek", "CL": "Chili", "CN": "China", "CX": "Christmaseiland", "CC": "Cocoseilanden", "CO": "Colombia", "KM": "Comoren", "CG": "Congo-Brazzaville", "CD": "Congo-Kinshasa", "CK": "Cookeilanden", "CR": "Costa Rica", "CU": "Cuba", "CW": "Curaçao", "CY": "Cyprus", "DK": "Denemarken", "DJ": "Djibouti", "DM": "Dominica", "DO": "Dominicaanse Republiek", "DE": "Duitsland", "EC": "Ecuador", "EG": "Egypte", "SV": "El Salvador", "GQ": "Equatoriaal-Guinea", "ER": "Eritrea", "EE": "Estland", "ET": "Ethiopië", "FO": "Faeröer", "FK": "Falklandeilanden", "FJ": "Fiji", "PH": "Filipijnen", "FI": "Finland", "FR": "Frankrijk", "TF": "Franse Zuidelijke en Antarctische Gebiede", "GF": "Frans-Guyana", "PF": "Frans-Polynesië", "GA": "Gabon", "GM": "Gambia", "GE": "Georgië", "GH": "Ghana", "GI": "Gibraltar", "GD": "Grenada", "GR": "Griekenland", "GL": "Groenland", "GP": "Guadeloupe", "GU": "Guam", "GT": "Guatemala", "GG": "Guernsey", "GN": "Guinee", "GW": "Guinee-Bissau", "GY": "Guyana", "HT": "Haïti", "HM": "Heard en McDonaldeilanden", "HN": "Honduras", "HU": "Hongarije", "HK": "Hongkong", "IE": "Ierland", "IS": "IJsland", "IN": "India", "ID": "Indonesië", "IQ": "Irak", "IR": "Iran", "IL": "Israël", "IT": "Italië", "CI": "Ivoorkust", "JM": "Jamaica", "JP": "Japan", "YE": "Jemen", "JE": "Jersey", "JO": "Jordanië", "KY": "Kaaimaneilanden", "CV": "Kaapverdië", "CM": "Kameroen", "KZ": "Kazachstan", "KE": "Kenia", "KG": "Kirgizië", "KI": "Kiribati", "UM": "Kleine afgelegen eilanden van de Verenigde Staten", "KW": "Koeweit", "XK": "Kosovo", "HR": "Kroatië", "LA": "Laos", "LS": "Lesotho", "LV": "Letland", "LB": "Libanon", "LR": "Liberia", "LY": "Libië", "LI": "Liechtenstein", "LT": "Litouwen", "LU": "Luxemburg", "MO": "Macau", "MK": "Macedonië", "MG": "Madagaskar", "MW": "Malawi", "MV": "Maldiven", "MY": "Maleisië", "ML": "Mali", "MT": "Malta", "IM": "Man", "MA": "Marokko", "MH": "Marshalleilanden", "MQ": "Martinique", "MR": "Mauritanië", "MU": "Mauritius", "YT": "Mayotte", "MX": "Mexico", "FM": "Micronesia", "MD": "Moldavië", "MC": "Monaco", "MN": "Mongolië", "ME": "Montenegro", "MS": "Montserrat", "MZ": "Mozambique", "MM": "Myanmar", "NA": "Namibië", "NR": "Nauru", "NL": "Nederland", "NP": "Nepal", "NI": "Nicaragua", "NC": "Nieuw-Caledonië", "NZ": "Nieuw-Zeeland", "NE": "Niger", "NG": "Nigeria", "NU": "Niue", "MP": "Noordelijke Marianen", "KP": "Noord-Korea", "NO": "Noorwegen", "NF": "Norfolk", "UG": "Oeganda", "UA": "Oekraïne", "UZ": "Oezbekistan", "OM": "Oman", "AT": "Oostenrijk", "TL": "Oost-Timor", "PK": "Pakistan", "PW": "Palau", "PS": "Palestina", "PA": "Panama", "PG": "Papua-Nieuw-Guinea", "PY": "Paraguay", "PE": "Peru", "PN": "Pitcairneilanden", "PL": "Polen", "PT": "Portugal", "PR": "Puerto Rico", "QA": "Qatar", "RE": "Réunion", "RO": "Roemenië", "RU": "Rusland", "RW": "Rwanda", "BL": "Saint-Barthélemy", "KN": "Saint Kitts en Nevis", "LC": "Saint Lucia", "PM": "Saint-Pierre en Miquelon", "VC": "Saint Vincent en de Grenadines", "SB": "Salomonseilanden", "WS": "Samoa", "SM": "San Marino", "SA": "Saoedi-Arabië", "ST": "Sao Tomé en Principe", "SN": "Senegal", "RS": "Servië", "SC": "Seychellen", "SL": "Sierra Leone", "SG": "Singapore", "SH": "Sint-Helena, Ascension en Tristan da Cunha", "MF": "Sint-Maarten", "SX": "Sint Maarten", "SI": "Slovenië", "SK": "Slowakije", "SD": "Soedan", "SO": "Somalië", "ES": "Spanje", "SJ": "Spitsbergen en Jan Mayen", "LK": "Sri Lanka", "SR": "Suriname", "SZ": "Swaziland", "SY": "Syrië", "TJ": "Tadzjikistan", "TW": "Taiwan", "TZ": "Tanzania", "TH": "Thailand", "TG": "Togo", "TK": "Tokelau", "TO": "Tonga", "TT": "Trinidad en Tobago", "TD": "Tsjaad", "CZ": "Tsjechië", "TN": "Tunesië", "TR": "Turkije", "TM": "Turkmenistan", "TC": "Turks- en Caicoseilanden", "TV": "Tuvalu", "UY": "Uruguay", "VU": "Vanuatu", "VA": "Vaticaanstad", "VE": "Venezuela", "AE": "Verenigde Arabische Emiraten", "US": "Verenigde Staten", "GB": "Verenigd Koninkrijk", "VN": "Vietnam", "WF": "Wallis en Futuna", "EH": "Westelijke Sahara", "BY": "Wit-Rusland", "ZM": "Zambia", "ZW": "Zimbabwe", "ZA": "Zuid-Afrika", "GS": "Zuid-Georgia en de Zuidelijke Sandwicheilanden", "KR": "Zuid-Korea", "SS": "Zuid-Soedan", "SE": "Zweden", "CH": "Zwitserland", }; $scope.fnLC = function (a) { var b = a.split("_"); return l[b[0]]; } $scope.fnC = function (a) { return c[a]; } } ]); app.controller('analytics', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.pageowl.app/rest/analytics/connect?websiteid=' + h.siteId(), dateformat: "YYYYMMDD", dimensionprefix: 'ga:', urlbase: 'analytics', switchclient: true, report_endpoint: '/rest/analytics/v4', } $scope.gloMetrics = h.metrics('analytics'); /* $scope.gloMetrics = { sessions: { name: 'Sessions', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, users: { name: 'Users', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, pageviews: { name: 'Pageviews', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, pageviewsPerSession: { name: 'Pageviews / session', typefilter: 'number', filtercomparator: 2 ,'datemerge':'avg'}, avgSessionDuration: { name: 'Average session duration', typefilter: 'secondsToHHmmss', filtercomparator: 0 ,'datemerge':'avg'}, percentNewSessions: { name: 'Percentrage new sessions', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, bounceRate: { name: 'Bounce Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, goalCompletionsAll: { name: 'Goals', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, goalValueAll: { name: 'Goalvalue', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, goalConversionRateAll: { name: 'Goalconversion ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, goalAbandonRateAll: { name: 'Doel verlatings ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, uniquePageviews: { name: 'Unique Pageviews', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, avgTimeOnPage: { name: 'Avg time on page', typefilter: 'secondsToHHmmss', filtercomparator: 0 ,'datemerge':'avg'}, entrances: { name: 'Entrances', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, exitRate: { name: 'Exit rate', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, pageValue: { name: 'Pagevalue', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, exits: { name: 'Exits', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, ROAS: { name: 'ROAS', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, costPerTransaction: { name: 'Cost per transaction', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, totalValue: { name: 'Total value', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, adCost: { name: 'Ad Cost', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, CPC: { name: 'CPC', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, adClicks: { name: 'Ad Clicks', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, transactions: { name: 'Transacions', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, uniquePurchases: { name: 'Unique Purchases', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, totalValue: { name: 'Total Value', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, revenuePerTransaction: { name: 'Revenue per Transaction', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, itemQuantity: { name: 'Item Quentity', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, itemsPerPurchase: { name: 'items / purchase', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, };*/ $scope.selectedmetrics = {}; $scope.reportconfig = { channels: { views: { all: { name: 'All', view: 'all', viewfilters: ['goals'], first: { dimension: 'date' }, second: { dimension: 'channelGrouping', sort: 'sessions', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / channel', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Users / channel', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'Pagina weergaven', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Pageviews / channel', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Pageviews per session /channel', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, organic: { name: 'Organic', view: 'organic', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Organic Search' }, second: { dimension: 'keyword', channel: 'Organic Search', sort: 'users', sortorder: 'DESCENDING' }, third: { dimension: 'source', channel: 'Organic Search', sort: 'users', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], }, }, paid: { name: 'Betaalde zoekopdrachten', view: 'paid', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Paid Search' }, second: { dimension: 'keyword', channel: 'Paid Search', sort: 'sessions', sortorder: 'DESCENDING' }, third: { dimension: 'year', channel: 'Paid Search', sort: 'sessions', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, display: { name: 'Display', view: 'display', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Display' }, second: { dimension: 'source', channel: 'Display', sort: 'sessions', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, social: { name: 'Social', view: 'social', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Social' }, second: { dimension: 'socialNetwork', channel: 'Social', sort: 'sessions', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, referral: { name: 'Referral', view: 'referral', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Referral', sort: 'date', sortorder: 'ASCENDING', }, second: { dimension: 'source', channel: 'Referral', sort: 'sessions', sortorder: 'DESCENDING' }, third: { dimension: 'year', channel: 'Referral', sort: 'year', sortorder: 'ASCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, email: { name: 'E-mail', view: 'email', viewfilters: ['goals'], first: { dimension: 'date', channel: 'Email' }, second: { dimension: 'source', channel: 'Email', sort: 'sessions', sortorder: 'DESCENDING', }, third: { dimension: 'year', channel: 'Email', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, other: { name: 'Overig', view: 'other', viewfilters: ['goals'], first: { dimension: 'date', channel: '(Other)' }, second: { dimension: 'source', channel: '(Other)', sort: 'sessions', sortorder: 'DESCENDING', }, third: { dimension: 'year', channel: '(Other)', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, }, }, audience: { views: { local: { name: 'Lokaal', view: 'local', viewfilters: ['goals'], first: { dimension: 'country', sort: 'sessions', sortorder: 'DESCENDING' }, second: { dimension: 'country', sort: 'sessions', sortorder: 'DESCENDING' }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], }, }, age: { name: 'Leeftijd', view: 'age', viewfilters: ['goals'], first: { dimension: 'date', dimension2: 'userAgeBracket' }, second: { dimension: 'userAgeBracket', sort: 'userAgeBracket', sortorder: 'ASCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'linemulti', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, gender: { name: 'Geslacht', view: 'gender', viewfilters: ['goals'], first: { dimension: 'date', dimension2: 'userGender', sort: 'userGender', sortorder: 'ASCENDING', }, second: { dimension: 'userGender', sort: 'userGender', sortorder: 'ASCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'linemulti', dataset: 'first', graphs: ['male', 'female'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, device: { name: 'Apparaten', view: 'device', viewfilters: ['goals'], first: { dimension: 'date', dimension2: 'deviceCategory' }, second: { dimension: 'deviceCategory', sort: 'deviceCategory', sortorder: 'ASCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'linemulti', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, mobile: { name: 'Mobiel', view: 'mobile', viewfilters: ['goals'], first: { dimension: 'mobileDeviceInfo', sort: 'sessions', sortorder: 'DESCENDING' }, second: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'sessions', selectedmetrics: { 'sessions': true, 'users': true, 'pageviews': true, 'pageviewsPerSession': true, 'avgSessionDuration': true, 'percentNewSessions': true, 'bounceRate': true, 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true }, metrics: ['sessions', 'users', 'pageviews', 'pageviewsPerSession', 'avgSessionDuration', 'percentNewSessions', 'bounceRate', 'goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { sessions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], users: [{ 'chart': 'c1', 'name': 'Users', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], avgSessionDuration: [{ 'chart': 'c1', 'name': 'Average session duration', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], percentNewSessions: [{ 'chart': 'c1', 'name': 'Percentage new Sessions', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'progress', dataset: 'first', graphs: ['desktop', 'mobile', 'tablet'] }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], }, } }, }, conversions: { views: { goals: { name: 'Doelen', view: 'goals', first: { dimension: 'date', }, second: { dimension: 'goalCompletionsAll' }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'goalCompletionsAll', selectedmetrics: { 'goalCompletionsAll': true, 'goalValueAll': true, 'goalConversionRateAll': true, 'goalAbandonRateAll': true }, metrics: ['goalCompletionsAll', 'goalValueAll', 'goalConversionRateAll', 'goalAbandonRateAll'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { goalCompletionsAll: [{ 'chart': 'c1', 'name': 'Goals', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalAbandonRateAll: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalValueAll: [{ 'chart': 'c1', 'name': 'Goalvalue', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], goalConversionRateAll: [{ 'chart': 'c1', 'name': 'Goalconverions ratio', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, /* DIT KLOPT NIET, HEB NOG GEEN DOELEN OF ECOMMERSE DATA*/ ecommerce: { name: 'E-commerce', view: 'ecommerce', first: { dimension: 'date' }, second: { dimension: 'adMatchedQuery', }, third: { dimension: 'channelGrouping' }, selectedmetric: 'transactions', selectedmetrics: { 'transactions': true, 'uniquePurchases': true, 'totalValue': true, 'revenuePerTransaction': true, 'itemQuantity': true, 'itemsPerPurchase': true }, metrics: ['transactions', 'uniquePurchases', 'totalValue', 'revenuePerTransaction', 'itemQuantity', 'itemsPerPurchase'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { transactions: [{ 'chart': 'c1', 'name': 'transactions', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'third' }], uniquePurchases: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'third' }], totalValue: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], revenuePerTransaction: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], itemQuantity: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], itemsPerPurchase: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first', graphs: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, }, }, pages: { views: { all: { name: 'Alle pagina\'s', view: 'all', viewfilters: ['goals', 'channels'], first: { dimension: 'date', }, second: { dimension: 'pagePath', sort: 'pageviews', sortorder: 'DESCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'pageviews', selectedmetrics: { 'pageviews': true, 'uniquePageviews': true, 'pageviewsPerSession': true, 'avgTimeOnPage': true, 'entrances': true, 'bounceRate': true, 'exitRate': true, 'pageValue': true }, metrics: ['pageviews', 'uniquePageviews', 'pageviewsPerSession', 'avgTimeOnPage', 'entrances', 'bounceRate', 'exitRate', 'pageValue'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], entrances: [{ 'chart': 'c1', 'name': 'Ingangen', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], pageValue: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], }, }, /* NIET HELAAM LEKKER */ landing: { name: 'Landingspagina\'s', view: 'landing', first: { dimension: 'date' }, second: { dimension: 'landingPagePath', sort: 'pageviews', sortorder: 'DESCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'pageviews', selectedmetrics: { 'pageviews': true, 'uniquePageviews': true, 'pageviewsPerSession': true, 'avgTimeOnPage': true, 'entrances': true, 'bounceRate': true, 'exitRate': true, 'pageValue': true }, metrics: ['pageviews', 'uniquePageviews', 'pageviewsPerSession', 'avgTimeOnPage', 'entrances', 'bounceRate', 'exitRate', 'pageValue'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], entrances: [{ 'chart': 'c1', 'name': 'Ingangen', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], pageValue: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], }, }, exit: { name: 'Exit Pagina\'s', view: 'exit', first: { dimension: 'date' }, second: { dimension: 'exitPagePath', sort: 'exits', sortorder: 'DESCENDING', }, third: { dimension: 'year', sort: 'year', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'exits', selectedmetrics: { 'exits': true, 'uniquePageviews': true, 'avgTimeOnPage': true, 'exitRate': true }, metrics: ['exits', 'uniquePageviews', 'avgTimeOnPage', 'exitRate'], table: { dataset: 'second' }, panels: { dataset: 'third' }, charts: { exits: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'progress', dataset: 'second' }], }, }, }, }, events: { views: { all: { name: 'Alle pagina\'s', view: 'all', first: { dimension: 'date', }, second: { dimension: 'pagePath', sort: 'pageviews', sortorder: 'DESCENDING', }, selectedmetric: 'pageviews', selectedmetrics: { 'pageviews': true, 'uniquePageviews': true, 'pageviewsPerSession': true, 'avgTimeOnPage': true, 'entrances': true, 'bounceRate': true, 'exitRate': true, 'pageValue': true }, metrics: ['pageviews', 'uniquePageviews', 'pageviewsPerSession', 'avgTimeOnPage', 'entrances', 'bounceRate', 'exitRate', 'pageValue'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], entrances: [{ 'chart': 'c1', 'name': 'Ingangen', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], pageValue: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, /* NIET HELAAM LEKKER */ landing: { name: 'Landingspagina\'s', view: 'landing', first: { dimension: 'date' }, second: { dimension: 'landingPagePath', sort: 'pageviews', sortorder: 'DESCENDING', }, selectedmetric: 'pageviews', selectedmetrics: { 'pageviews': true, 'uniquePageviews': true, 'pageviewsPerSession': true, 'avgTimeOnPage': true, 'entrances': true, 'bounceRate': true, 'exitRate': true, 'pageValue': true }, metrics: ['pageviews', 'uniquePageviews', 'pageviewsPerSession', 'avgTimeOnPage', 'entrances', 'bounceRate', 'exitRate', 'pageValue'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { pageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], pageviewsPerSession: [{ 'chart': 'c1', 'name': 'Pageviews per session', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], entrances: [{ 'chart': 'c1', 'name': 'Ingangen', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounceRate: [{ 'chart': 'c1', 'name': 'Bounce ratio', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], pageValue: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, exit: { name: 'Exit Pagina\'s', view: 'exit', first: { dimension: 'date' }, second: { dimension: 'exitPagePath', sort: 'exits', sortorder: 'DESCENDING', }, selectedmetric: 'exits', selectedmetrics: { 'exits': true, 'uniquePageviews': true, 'avgTimeOnPage': true, 'exitRate': true }, metrics: ['exits', 'uniquePageviews', 'avgTimeOnPage', 'exitRate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { exits: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], uniquePageviews: [{ 'chart': 'c1', 'name': 'PageViews', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avgTimeOnPage: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], exitRate: [{ 'chart': 'c1', 'name': '_', type: 'line', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, }, } } $scope.setAnalyticsCustomer = function(data) { $http.get('/rest/' + $scope.cfg.urlbase + '/profileid.set', { params: { websiteid: h.siteId(), profileid: data.profileid, accountId: data.accountId, webPropertyId: data.webPropertyId, } }).then(function successCallback(res) { $scope.getReportData(); }); } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('matomo', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function ($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'matomo', switchclient: true, report_endpoint: '/rest/matomo/report', } $scope.gloMetrics = { nb_visits: { name: 'Sessions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, nb_actions: { name: 'Actions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, nb_actions_per_visit: { name: 'Actions/Visit', typefilter: 'number', filtercomparator: 2, 'datemerge': 'avg' }, bounce_rate: { name: 'Bounce Rate', typefilter: 'number', filtercomparator: 0, 'after': '%', 'datemerge': 'avg' }, avg_time_on_site: { name: 'Avg Session Duration', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, nb_hits: { name: 'Pageviews', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, avg_time_on_page: { name: 'Avg Time on Page', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, nb_conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, revenue: { name: 'Revenue', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, nb_searches: { name: 'Site Searches', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, }; $scope.selectedmetrics = {}; $scope.reportconfig = { visitors: { views: { all: { name: 'All', view: 'all', first: { dimension: 'date' }, second: { dimension: 'referrerType', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'nb_actions_per_visit': true, 'avg_time_on_site': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / referrer', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Actions / referrer', type: 'pie', dataset: 'second' }], nb_actions_per_visit: [{ 'chart': 'c1', 'name': 'Actions per Visit', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Actions/Visit / referrer', type: 'bar', dataset: 'second' }], avg_time_on_site: [{ 'chart': 'c1', 'name': 'Avg Session Duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, geographic: { name: 'Geographic', view: 'geographic', first: { dimension: 'country', sort: 'nb_visits', sortorder: 'DESCENDING' }, second: { dimension: 'country', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'nb_actions_per_visit': true, 'avg_time_on_site': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions_per_visit: [{ 'chart': 'c1', 'name': 'Actions per Visit', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], avg_time_on_site: [{ 'chart': 'c1', 'name': 'Avg Session Duration', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], }, }, device: { name: 'Device', view: 'device', first: { dimension: 'deviceType', sort: 'nb_visits', sortorder: 'DESCENDING' }, second: { dimension: 'deviceType', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'nb_actions_per_visit': true, 'avg_time_on_site': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions_per_visit: [{ 'chart': 'c1', 'name': 'Actions per Visit', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], avg_time_on_site: [{ 'chart': 'c1', 'name': 'Avg Session Duration', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'bar', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], }, }, browser: { name: 'Browser', view: 'browser', first: { dimension: 'browser', sort: 'nb_visits', sortorder: 'DESCENDING' }, second: { dimension: 'browser', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], }, }, os: { name: 'Operating System', view: 'os', first: { dimension: 'os', sort: 'nb_visits', sortorder: 'DESCENDING' }, second: { dimension: 'os', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'first' }, panels: { dataset: 'second' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'progress', dataset: 'first' }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'first' }], }, }, }, }, channels: { views: { all: { name: 'All', view: 'all', first: { dimension: 'date' }, second: { dimension: 'referrerType', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'nb_actions_per_visit': true, 'avg_time_on_site': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / channel', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Actions / channel', type: 'pie', dataset: 'second' }], nb_actions_per_visit: [{ 'chart': 'c1', 'name': 'Actions per Visit', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], avg_time_on_site: [{ 'chart': 'c1', 'name': 'Avg Session Duration', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, organic: { name: 'Organic', view: 'organic', first: { dimension: 'date' }, second: { dimension: 'searchEngine', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, social: { name: 'Social', view: 'social', first: { dimension: 'date' }, second: { dimension: 'socialNetwork', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, referral: { name: 'Referral', view: 'referral', first: { dimension: 'date' }, second: { dimension: 'website', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, campaigns: { name: 'Campaigns', view: 'campaigns', first: { dimension: 'date' }, second: { dimension: 'campaign', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true, 'bounce_rate': true }, metrics: ['nb_visits', 'nb_actions', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'pie', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': '_', type: 'bar', dataset: 'second' }], }, }, }, }, pages: { views: { all: { name: 'All Pages', view: 'all', first: { dimension: 'date' }, second: { dimension: 'pageUrl', sort: 'nb_hits', sortorder: 'DESCENDING' }, selectedmetric: 'nb_hits', selectedmetrics: { 'nb_hits': true, 'nb_visits': true, 'bounce_rate': true, 'avg_time_on_page': true }, metrics: ['nb_hits', 'nb_visits', 'bounce_rate', 'avg_time_on_page'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_hits: [{ 'chart': 'c1', 'name': 'Pageviews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], avg_time_on_page: [{ 'chart': 'c1', 'name': 'Avg Time on Page', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], }, }, titles: { name: 'Page Titles', view: 'titles', first: { dimension: 'date' }, second: { dimension: 'pageTitle', sort: 'nb_hits', sortorder: 'DESCENDING' }, selectedmetric: 'nb_hits', selectedmetrics: { 'nb_hits': true, 'nb_visits': true, 'bounce_rate': true, 'avg_time_on_page': true }, metrics: ['nb_hits', 'nb_visits', 'bounce_rate', 'avg_time_on_page'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_hits: [{ 'chart': 'c1', 'name': 'Pageviews', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], avg_time_on_page: [{ 'chart': 'c1', 'name': 'Avg Time on Page', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Pages', type: 'progress', dataset: 'second' }], }, }, entry: { name: 'Entry Pages', view: 'entry', first: { dimension: 'date' }, second: { dimension: 'entryPage', sort: 'nb_visits', sortorder: 'DESCENDING' }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'bounce_rate': true }, metrics: ['nb_visits', 'bounce_rate'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Entry Pages', type: 'progress', dataset: 'second' }], bounce_rate: [{ 'chart': 'c1', 'name': 'Bounce Rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Entry Pages', type: 'progress', dataset: 'second' }], }, }, exit: { name: 'Exit Pages', view: 'exit', first: { dimension: 'date' }, second: { dimension: 'exitPage', sort: 'nb_visits', sortorder: 'DESCENDING' }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true }, metrics: ['nb_visits'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Exit Pages', type: 'progress', dataset: 'second' }], }, }, }, }, events: { views: { categories: { name: 'Event Categories', view: 'categories', first: { dimension: 'date' }, second: { dimension: 'eventCategory', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true }, metrics: ['nb_visits', 'nb_actions'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Categories', type: 'bar', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Categories', type: 'pie', dataset: 'second' }], }, }, actions: { name: 'Event Actions', view: 'actions', first: { dimension: 'date' }, second: { dimension: 'eventAction', sort: 'nb_visits', sortorder: 'DESCENDING', 'allowcomp': true }, selectedmetric: 'nb_visits', selectedmetrics: { 'nb_visits': true, 'nb_actions': true }, metrics: ['nb_visits', 'nb_actions'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { nb_visits: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Actions', type: 'bar', dataset: 'second' }], nb_actions: [{ 'chart': 'c1', 'name': 'Actions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Top Actions', type: 'pie', dataset: 'second' }], }, }, }, }, }; // Object for modal form binding (dot rule for ng-include child scope) $scope.matomoForm = { url: '', token: '', error: null, connecting: false }; // Custom connect function for Matomo (form-based, not OAuth) $scope.fnMatomoConnect = function () { $scope.matomoForm.error = null; $scope.matomoForm.connecting = true; $http.post('/rest/matomo/connect', { websiteid: h.siteId(), url: $scope.matomoForm.url, token_auth: $scope.matomoForm.token, }).then(function (res) { $scope.matomoForm.connecting = false; if (res.data.error) { $scope.matomoForm.error = res.data.error; } else { $scope.getCustomer(); } }, function () { $scope.matomoForm.connecting = false; $scope.matomoForm.error = 'Connection failed. Please check the URL and try again.'; }); } // Custom site selector for Matomo $scope.setMatomoCustomer = function (data) { $http.get('/rest/matomo/profileid.set', { params: { websiteid: h.siteId(), idsite: data.idsite } }).then(function () { $scope.getReportData(); }); } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('youtube', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.pageowl.app/rest/youtube/connect?site_id=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'youtube', report_endpoint: '/rest/youtube/report', } $scope.gloMetrics = h.metrics('youtube'); /* $scope.gloMetrics = { commentCount: { name: 'Clicks', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscriberCount: { name: 'Impressions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, videoCount: { name: 'Date', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, viewCount: { name: 'Avg cost / Click', typefilter: 'number', filtercomparator: 0, 'datemerge': 'avg' }, views: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, shares: { name: 'Conversions', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, likes: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, dislikes: { name: 'Cost / conversion', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, comments: { name: 'Conversion rate', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscribersGained: { name: 'AbsoluteTopImpressionPercentage', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, subscribersLost: { name: 'Conversion value', typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, estimatedMinutesWatched: { name: 'Zoekimpressie aandeel', typefilter: 'number', filtercomparator: 1, 'datemerge': 'sum' }, annotationCloseRate: { name: 'Top Positie', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, annotationClickThroughRate: { name: 'ROAS', typefilter: 'number', filtercomparator: 2, 'after': '%', 'datemerge': 'avg' }, averageViewDuration: { name: 'ROAS', typefilter: 'secondsToHHmmss', filtercomparator: 0, 'datemerge': 'avg' }, }; */ $scope.fnFormatAd = function(d) { return d.HeadlinePart1 + d.HeadlinePart2 + d.Description + d.CreativeDestinationUrl + d.CreativeFinalUrls; } $scope.reportconfig = { channel: { views: { overview: { name: 'Overview', view: 'overview', second: { dimension: 'Date', report: 'analytics', 'allowcomp': true }, third: { dimension: 'insightTrafficSourceType', report: 'analytics', metrics: ['views', 'estimatedMinutesWatched'] }, selectedmetric: 'views', selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'SearchImpressionShare': true, 'roas': true }, metrics: ['views', 'shares', 'likes', 'dislikes', 'comments', 'subscribersGained', 'subscribersLost', 'estimatedMinutesWatched', 'annotationCloseRate', 'annotationClickThroughRate', 'averageViewDuration'], //table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { views: [{ 'chart': 'c1', 'name': 'views', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], shares: [{ 'chart': 'c1', 'name': 'Shares', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], likes: [{ 'chart': 'c1', 'name': 'Likes', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], dislikes: [{ 'chart': 'c1', 'name': 'Dislikes', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], comments: [{ 'chart': 'c1', 'name': 'Comments', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], subscribersGained: [{ 'chart': 'c1', 'name': 'Subscribers Gained', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], subscribersLost: [{ 'chart': 'c1', 'name': 'Subscribers Lost', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], estimatedMinutesWatched: [{ 'chart': 'c1', 'name': 'Minutes Watched', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'second', date: true },{ 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], }, }, anderekak: { name: 'Overview', view: 'anderekak', first: { dimension: 'Date', report: 'channelstats' }, selectedmetric: 'views', selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'SearchImpressionShare': true, 'roas': true }, metrics: ['commentCount', 'subscriberCount', 'videoCount', 'viewCount'], //table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { views: [{ 'chart': 'c1', 'name': 'views', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], shares: [{ 'chart': 'c1', 'name': 'Shares', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], likes: [{ 'chart': 'c1', 'name': 'Likes', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], dislikes: [{ 'chart': 'c1', 'name': 'Dislikes', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], comments: [{ 'chart': 'c1', 'name': 'Comments', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], subscribersGained: [{ 'chart': 'c1', 'name': 'Subscribers Gained', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], subscribersLost: [{ 'chart': 'c1', 'name': 'Subscribers Lost', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], estimatedMinutesWatched: [{ 'chart': 'c1', 'name': 'Minutes Watched', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'second', date: true }, { 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'second', date: true },{ 'chart': 'c2', 'name': 'Views / Source', 'metric':'views', type: 'bar', dataset: 'third' }], }, }, videos: { name: 'Ad Groups', view: 'videos', first: { dimension: 'Date', report: 'videos' }, //second: { dimension: 'AverageCpm', sort: 'sessions', sortorder: 'DESCENDING', }, selectedmetric: 'Clicks', metrics: ['Clicks'], }, }, } } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('googleads', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function ($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.marketingtracer.com/rest/googlexads/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'googlexads', switchclient: true, report_endpoint: '/rest/googlexads/report', } if ($rootScope.user.reseller?.config?.authdomain) { $scope.cfg.authurl = 'https://' + $rootScope.user.reseller.config.authdomain + '/rest/googlexads/connect?websiteid=' + h.siteId(); } $scope.gloMetrics = h.metrics('googlexads'); /* $scope.gloMetrics = { Clicks: { name: 'Clicks', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, Impressions: { name: 'Impressions', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, Date: { name: 'Date', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, AverageCpc: { name: 'Avg cost / Click', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'avg'}, Ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'sum'}, CostPerConversion: { name: 'Cost / conversion', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'avg'}, ConversionRate: { name: 'Conversion rate', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, AbsoluteTopImpressionPercentage: { name: 'AbsoluteTopImpressionPercentage', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, ConversionValue: { name: 'Conversion value', typefilter: 'number', filtercomparator: 0, 'before': '€' ,'datemerge':'sum'}, SearchImpressionShare: { name: 'Zoekimpressie aandeel', typefilter: 'number', filtercomparator: 2, 'after': '%' ,'datemerge':'avg'}, SearchAbsoluteTopImpressionShare: { name: 'Top Positie', typefilter: 'number', filtercomparator: 2, 'after': '%' ,'datemerge':'avg'}, roas: { name: 'ROAS', typefilter: 'number', filtercomparator: 2, 'after': '%' ,'datemerge':'avg'}, };*/ $scope.fnFormatAd = function (d) { return d.HeadlinePart1 + d.HeadlinePart2 + d.Description + d.CreativeDestinationUrl + d.CreativeFinalUrls; } $scope.reportconfig = { campaigns: { views: { all: { name: 'Campaigns', view: 'all', first: { dimension: 'Date', report: 'ACCOUNT_PERFORMANCE_REPORT' }, second: { dimension: 'CampaignName', report: 'CAMPAIGN_PERFORMANCE_REPORT', 'allowcomp': true }, selectedmetric: 'Clicks', selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'SearchImpressionShare': true, 'roas': true }, metrics: ['Clicks', 'Impressions', 'Cost', 'AverageCpc', 'Ctr', 'Conversions', 'CostPerConversion', 'ConversionRate', 'AbsoluteTopImpressionPercentage', 'ConversionValue', 'roas'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { Clicks: [{ 'chart': 'c1', 'name': 'Clicks', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Clicks / Campaign', type: 'progress', dataset: 'second' }], Impressions: [{ 'chart': 'c1', 'name': 'Impressions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Impressions / Campaign', type: 'progress', dataset: 'second' }], Cost: [{ 'chart': 'c1', 'name': 'Kosten', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Campaign', type: 'progress', dataset: 'second' }], AverageCpc: [{ 'chart': 'c1', 'name': 'Avg cost / Click', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'CPC / Campaign', type: 'progress', dataset: 'second' }], Ctr: [{ 'chart': 'c1', 'name': 'Ctr', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Ctr / Campaign', type: 'progress', dataset: 'second' }], Conversions: [{ 'chart': 'c1', 'name': 'Conversies', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversies / Campaign', type: 'progress', dataset: 'second' }], CostPerConversion: [{ 'chart': 'c1', 'name': 'Cost / conversion', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Cost / conversion / Campaign', type: 'progress', dataset: 'second' }], ConversionRate: [{ 'chart': 'c1', 'name': 'Conversion rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion rate / Campaign', type: 'progress', dataset: 'second' }], AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Average position / Campaign', type: 'progress', dataset: 'second' }], ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion value / Campaign', type: 'progress', dataset: 'second' }], }, }, adgroups: { name: 'Ad Groups', view: 'adgroups', first: { dimension: 'Date', report: 'ACCOUNT_PERFORMANCE_REPORT' }, second: { dimension: 'AdGroupName', report: 'ADGROUP_PERFORMANCE_REPORT', 'allowcomp': true }, //second: { dimension: 'AverageCpm', sort: 'sessions', sortorder: 'DESCENDING', }, selectedmetric: 'Clicks', selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'SearchImpressionShare': true, 'roas': true }, metrics: ['Clicks', 'Impressions', 'Cost', 'AverageCpc', 'Ctr', 'Conversions', 'CostPerConversion', 'ConversionRate', 'AbsoluteTopImpressionPercentage', 'ConversionValue', 'roas'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { Clicks: [{ 'chart': 'c1', 'name': 'Clicks', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Clicks / Adgroup', type: 'progress', dataset: 'second' }], Impressions: [{ 'chart': 'c1', 'name': 'Impressions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Impressions / Adgroup', type: 'progress', dataset: 'second' }], Cost: [{ 'chart': 'c1', 'name': 'Kosten', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Adgroup', type: 'progress', dataset: 'second' }], AverageCpc: [{ 'chart': 'c1', 'name': 'Avg cost / Click', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'CPC / Adgroup', type: 'progress', dataset: 'second' }], Ctr: [{ 'chart': 'c1', 'name': 'Ctr', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Ctr / Adgroup', type: 'progress', dataset: 'second' }], Conversions: [{ 'chart': 'c1', 'name': 'Conversies', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversies / Adgroup', type: 'progress', dataset: 'second' }], CostPerConversion: [{ 'chart': 'c1', 'name': 'Cost / conversion', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Cost / conversion / Adgroup', type: 'progress', dataset: 'second' }], ConversionRate: [{ 'chart': 'c1', 'name': 'Conversion rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion rate / Adgroup', type: 'progress', dataset: 'second' }], AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Average position / Adgroup', type: 'progress', dataset: 'second' }], ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion value / Adgroup', type: 'progress', dataset: 'second' }], }, }, keywords: { name: 'Keywords', view: 'keywords', first: { dimension: 'Date', report: 'ACCOUNT_PERFORMANCE_REPORT' }, second: { dimension: 'Query', report: 'SEARCH_QUERY_PERFORMANCE_REPORT', 'allowcomp': true }, selectedmetric: 'Clicks', selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'roas': true }, metrics: ['Clicks', 'Impressions', 'Cost', 'AverageCpc', 'Ctr', 'Conversions', 'CostPerConversion', 'ConversionRate', 'AbsoluteTopImpressionPercentage', 'ConversionValue', 'roas'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { Clicks: [{ 'chart': 'c1', 'name': 'Clicks', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Clicks / Keyword', type: 'bar', dataset: 'second' }], Impressions: [{ 'chart': 'c1', 'name': 'Impressions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Impressions / Keyword', type: 'bar', dataset: 'second' }], Cost: [{ 'chart': 'c1', 'name': 'Kosten', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Keyword', type: 'bar', dataset: 'second' }], AverageCpc: [{ 'chart': 'c1', 'name': 'Avg cost / Click', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'CPC / Keyword', type: 'bar', dataset: 'second' }], Ctr: [{ 'chart': 'c1', 'name': 'Ctr', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Ctr / Keyword', type: 'bar', dataset: 'second' }], Conversions: [{ 'chart': 'c1', 'name': 'Conversies', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversies / Keyword', type: 'bar', dataset: 'second' }], CostPerConversion: [{ 'chart': 'c1', 'name': 'Cost / conversion', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Cost / conversion / Keyword', type: 'bar', dataset: 'second' }], ConversionRate: [{ 'chart': 'c1', 'name': 'Conversion rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion rate / Keyword', type: 'bar', dataset: 'second' }], AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Average position / Keyword', type: 'bar', dataset: 'second' }], ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion value / Keyword', type: 'bar', dataset: 'second' }], }, }, // ads: { // name: 'Ads', // view: 'ads', // first: { dimension: 'Date', report: 'ACCOUNT_PERFORMANCE_REPORT', 'allowcomp': true }, // second: { dimension: 'HeadlinePart1,HeadlinePart2,Description,CreativeDestinationUrl,CreativeFinalUrls', function: 'fnFormatAd', report: 'AD_PERFORMANCE_REPORT' }, // selectedmetric: 'Clicks', // selectedmetrics: { 'Clicks': true, 'Impressions': true, 'Cost': true, 'AverageCpc': true, 'Ctr': true, 'Conversions': true, 'CostPerConversion': true, 'ConversionRate': true, 'AbsoluteTopImpressionPercentage': true, 'ConversionValue': true, 'roas': true }, // metrics: ['Clicks', 'Impressions', 'Cost', 'AverageCpc', 'Ctr', 'Conversions', 'CostPerConversion', 'ConversionRate', 'AbsoluteTopImpressionPercentage', 'ConversionValue', 'roas'], // table: { dataset: 'second' }, // panels: { dataset: 'second' }, // charts: { // Clicks: [{ 'chart': 'c1', 'name': 'Clicks', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Clicks / Ad', type: 'bar', dataset: 'second' }], // Impressions: [{ 'chart': 'c1', 'name': 'Impressions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Impressions / Ad', type: 'bar', dataset: 'second' }], // Cost: [{ 'chart': 'c1', 'name': 'Kosten', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Ad', type: 'bar', dataset: 'second' }], // AverageCpc: [{ 'chart': 'c1', 'name': 'Avg cost / Click', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'CPC / Ad', type: 'bar', dataset: 'second' }], // Ctr: [{ 'chart': 'c1', 'name': 'Ctr', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Ctr / Ad', type: 'bar', dataset: 'second' }], // Conversions: [{ 'chart': 'c1', 'name': 'Conversies', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversies / Ad', type: 'bar', dataset: 'second' }], // CostPerConversion: [{ 'chart': 'c1', 'name': 'Cost / conversion', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Cost / conversion / Ad', type: 'bar', dataset: 'second' }], // ConversionRate: [{ 'chart': 'c1', 'name': 'Conversion rate', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion rate / Ad', type: 'bar', dataset: 'second' }], // AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Average position', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Average position / Ad', type: 'bar', dataset: 'second' }], // ConversionValue: [{ 'chart': 'c1', 'name': 'Conversion value', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Conversion value / Ad', type: 'bar', dataset: 'second' }], // }, // }, // conversions: { // name: 'Conversions', // view: 'conversions', // first: { dimension: 'Date', report: 'ACCOUNT_PERFORMANCE_REPORT' }, // second: { dimension: 'ConversionTypeName', report: 'ACCOUNT_PERFORMANCE_REPORT', 'allowcomp': true }, // selectedmetric: 'Conversions', // selectedmetrics: { 'Conversions': true, 'ConversionValue': true }, // //metrics: ['Conversions', 'CostPerConversion', 'ConversionRate', 'ConversionValue'], // metrics: ['Conversions','ConversionValue'], // table: { dataset: 'second' }, // panels: { dataset: 'second' }, // charts: { // Clicks: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // Impressions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // Cost: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // AverageCpc: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // Ctr: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // Conversions: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // CostPerConversion: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // ConversionRate: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // AbsoluteTopImpressionPercentage: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // ConversionValue: [{ 'chart': 'c1', 'name': 'Sessions', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': 'Sessions / Channel', type: 'bar', dataset: 'second' }], // }, // }, }, } } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('facebookads', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.marketingtracer.com/rest/facebookads/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'facebookads', report_endpoint: '/rest/facebookads/report', } $scope.gloMetrics = h.metrics('facebookads'); /* $scope.gloMetrics = { clicks: { name: 'Klikken', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, impressions: { name: 'Impressies', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, cpc: { name: 'cpc', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'avg'}, cpm: { name: 'cpm', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'avg'}, ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 2, 'after': '%' ,'datemerge':'avg'}, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0 ,'datemerge':'sum'}, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'before': '€' ,'datemerge':'sum'}, CostPerConversion: { name: $filter('translate')('metrics.spend')+' / conversie', typefilter: 'number', filtercomparator: 0, 'before': '€' ,'datemerge':'avg'}, ConversionRate: { name: 'Conversie ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' ,'datemerge':'avg'}, AveragePosition: { name: 'Gemiddelde positie', typefilter: 'number', filtercomparator: 0 ,'datemerge':'avg'}, ConversionValue: { name: 'Totale conversiewaarde', typefilter: 'number', filtercomparator: 0, 'before': '€' ,'datemerge':'sum'}, relevance_score: { name: 'Relevantie Score', typefilter: 'number', filtercomparator: 0, hide: { 'panel': true } ,'datemerge':'avg'}, spend: { name: 'Kosten', typefilter: 'number', filtercomparator: 2, 'before': '€' ,'datemerge':'sum'}, }; */ $scope.reportconfig = { campaigns: { views: { all: { name: 'Campagnes', view: 'all', viewfilters: ['campaigns'], first: { dimension: 'date', level: 'account', 'endpoint': 'insights' }, second: { dimension: 'campaign_name', level: 'campaign', 'endpoint': 'insights', 'summary': 'clicks,impressions,cpc,cpm', 'allowcomp': true }, selectedmetric: 'clicks', selectedmetrics: { 'clicks': true, 'impressions': true, 'cpc': true, 'cpm': true }, metrics: ['clicks', 'impressions', 'cpc', 'cpm','conversion_values','conversions','cost_per_conversion','website_ctr','website_purchase_roas'], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { clicks: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.clicks') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks') + ' / Campaign', type: 'bar', dataset: 'second' }], impressions: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.impressions') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions') + ' / Campaign', type: 'bar', dataset: 'second' }], cpc: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpc') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc') + ' / Campaign', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpm') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm') + ' / Campaign', type: 'bar', dataset: 'second' }], }, }, adsets: { name: 'Advertentie Sets', view: 'adsets', viewfilters: ['campaigns'], first: { dimension: 'date', level: 'account', 'endpoint': 'insights', 'summary': 'clicks,impressions,cpc,cpm,spend,ctr', 'allowcomp': true }, second: { dimension: 'adset_name', level: 'adset', endpoint: 'insights' }, third: { dimension: 'date', level: 'account', 'endpoint': 'insights' }, selectedmetric: 'clicks', selectedmetrics: { 'clicks': true, 'impressions': true, 'cpc': true, 'cpm': true, 'ctr': true, 'spend': true }, metrics: ['clicks', 'impressions', 'cpc', 'cpm', 'ctr', 'spend'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { clicks: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.clicks') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks') + ' / ad set', type: 'bar', dataset: 'second' }], impressions: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.impressions') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions') + ' / ad set', type: 'bar', dataset: 'second' }], cpc: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpc') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc') + ' / ad set', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpm') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm') + ' / ad set', type: 'bar', dataset: 'second' }], ctr: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.ctr') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.ctr') + ' / ad set', type: 'bar', dataset: 'second' }], spend: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.spend') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.spend') + ' / ad set', type: 'bar', dataset: 'second' }], }, }, ads: { name: 'Advertenties', view: 'ads', viewfilters: ['adsets', 'campaigns', 'ads'], first: { dimension: 'date', level: 'account', 'endpoint': 'insights', 'summary': 'clicks,impressions,cpc,cpm,spend,ctr', 'allowcomp': true }, second: { dimension: 'ad_name', level: 'ad', endpoint: 'insights' }, selectedmetric: 'clicks', selectedmetrics: { 'clicks': true, 'impressions': true, 'cpc': true, 'cpm': true, 'ctr': true, 'spend': true }, metrics: ['clicks', 'impressions', 'cpc', 'cpm', 'ctr', 'spend'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { clicks: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.clicks') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks') + ' / ad', type: 'bar', dataset: 'second' }], impressions: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.impressions') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions') + ' / ad', type: 'bar', dataset: 'second' }], cpc: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpc') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc') + ' / ad', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpm') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm') + ' / ad', type: 'bar', dataset: 'second' }], ctr: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.ctr') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.ctr') + ' / ad', type: 'bar', dataset: 'second' }], spend: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.spend') + ' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.spend') + ' / ad', type: 'bar', dataset: 'second' }], }, }, age: { name: 'Leeftijd', view: 'age', viewfilters: ['adsets', 'campaigns', 'ads'], first: { level: 'account', 'endpoint': 'insights', 'breakdowns': 'age', 'dimension': 'date', 'dimension2': 'age', 'summary': 'clicks,impressions,cpc,cpm', 'allowcomp': true }, second: { level: 'account', 'endpoint': 'insights', 'breakdowns': 'age', 'dimension': 'age' }, selectedmetric: 'clicks', selectedmetrics: { 'clicks': true, 'impressions': true, 'cpc': true, 'cpm': true }, metrics: ['clicks', 'impressions', 'cpc', 'cpm'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { clicks: [{ 'chart': 'c1', 'name': 'Klikken, leeftijd en tijd', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks') + ' / age group', type: 'bar', dataset: 'second' }], impressions: [{ 'chart': 'c1', 'name': 'Impressies,leeftijd en tijd', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions') + ' / age group', type: 'bar', dataset: 'second' }], cpc: [{ 'chart': 'c1', 'name': 'CPC,leeftijd en tijd', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm') + ' / age group', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': 'CPM,leeftijd en tijd', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc') + ' / age group', type: 'bar', dataset: 'second' }], }, }, gender: { name: 'Geslacht', view: 'gender', viewfilters: ['adsets', 'campaigns', 'ads'], first: { level: 'account', 'endpoint': 'insights', 'breakdowns': 'gender', 'dimension': 'date', 'dimension2': 'gender', 'summary': 'clicks,impressions,cpc,cpm', 'allowcomp': true }, second: { level: 'account', 'endpoint': 'insights', 'breakdowns': 'gender', 'dimension': 'gender' }, selectedmetric: 'clicks', selectedmetrics: { 'clicks': true, 'impressions': true, 'cpc': true, 'cpm': true }, metrics: ['clicks', 'impressions', 'cpc', 'cpm'], table: { dataset: 'second' }, panels: { dataset: 'first' }, charts: { clicks: [{ 'chart': 'c1', 'name': 'Klikken, geslacht per dag', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['male', 'female'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks') + ' / Gender', type: 'bar', dataset: 'second' }], impressions: [{ 'chart': 'c1', 'name': 'Impressies, geslacht per dag', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['male', 'female'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions') + ' / Gender', type: 'bar', dataset: 'second' }], cpc: [{ 'chart': 'c1', 'name': 'CPC, geslacht per dag', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['male', 'female'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc') + ' / Gender', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': 'CPM, geslacht per dag', type: 'linemulti', dataset: 'first', date: true, 'graphs': ['male', 'female'] }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm') + ' / Gender', type: 'bar', dataset: 'second' }], }, }, }, }, } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('facebookinsights', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.marketingtracer.com/rest/facebookinsights/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'facebookinsights', report_endpoint: '/rest/facebookinsights/report', } $scope.gloMetrics = h.metrics('facebookinsights'); /* $scope.gloMetrics = { fans: { name: 'Fans', typefilter: 'number', filtercomparator: 0 }, interacties: { name: 'Interactions', typefilter: 'number', filtercomparator: 0 }, bereik: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, };*/ //'page_fans','page_fans_country','page_fans_by_like_source','page_fans_gender_age' $scope.reportconfig = { page: { views: { fans: { name: 'Fans', view: 'fans', first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, second: { 'report': 'fans', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'fans', metrics: [], charts: { fans: [ { 'chart': 'c1', 'name': 'Total fans', type: 'line', dataset: 'page_fans_total', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c2', 'dimension2': 'type', 'name': 'Lost and new fans', type: 'linemulti', dataset: 'page_fans_delta', date: true, 'dimension': 'date', 'metric': 'change', 'graphs': ['page_fan_removes', 'page_fans_adds_paid', 'page_fans_adds_unpaid'] }, { 'chart': 'c3', 'name': 'Female fans / age ', type: 'bar', dataset: 'page_fans_female_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c4', 'name': 'Male fans / age ', type: 'bar', dataset: 'page_fans_male_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c5', 'name': 'Distribution Male and Female fans', type: 'pie', dataset: 'page_fans_age', 'dimension': 'key', 'metric': 'val' }, ], }, }, engagements: { name: 'Interactie', view: 'engagements', first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, second: { 'report': 'engagements', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'interacties', metrics: [], charts: { interacties: [ { 'chart': 'c1', 'dimension2': 'type', 'name': 'Total interactions / day', type: 'linemulti', dataset: 'engagements', date: true, 'dimension': 'date', 'metric': 'value', 'graphs': ['engagements'] }, { 'chart': 'c2', 'dimension2': 'type', 'name': 'Interactions / type', type: 'linemulti', dataset: 'engagements', date: true, 'dimension': 'date', 'metric': 'value', 'graphs': ['engagements_comments', 'engagements_likes', 'engagements_links', 'engagements_others'] }, { 'chart': 'c3', 'name': 'Female interactions / age ', type: 'bar', dataset: 'page_content_activity_female_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c4', 'name': 'Male interactions / age ', type: 'bar', dataset: 'page_content_activity_male_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c5', 'name': 'Interactions / age ', type: 'pie', dataset: 'page_content_activity_age', 'dimension': 'key', 'metric': 'val' }, ], }, }, reach: { name: 'Bereik', view: 'reach', first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, second: { 'report': 'reach', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'bereik', metrics: [], charts: { bereik: [ { 'chart': 'c1', 'name': 'Total bereik', type: 'line', dataset: 'page_impressions', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c2', 'dimension2': 'type', 'name': 'Bereik organisch en betaald', type: 'linemulti', dataset: 'page_impressions_type', date: true, 'dimension': 'date', 'metric': 'value', 'graphs': ['page_impressions_paid', 'page_impressions_organic'] }, { 'chart': 'c3', 'name': 'Female reach / age ', type: 'bar', dataset: 'page_impressions_female_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c4', 'name': 'Male reach / age ', type: 'bar', dataset: 'page_impressions_male_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c5', 'name': 'Reach / gender', type: 'pie', dataset: 'page_impressions_age', 'dimension': 'key', 'metric': 'val' }, ], }, }, posts: { name: 'Posts', view: 'posts', first: { 'report': 'posts' }, selectedmetric: 'posts', metrics: [], charts: { }, }, }, }, } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('instagram', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.pageowl.app/rest/instagram/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'instagram', report_endpoint: '/rest/instagram/report', } $scope.gloMetrics = h.metrics('instagram'); /*$scope.gloMetrics = { impressions: { name: 'Fans', typefilter: 'number', filtercomparator: 0 }, reach: { name: 'Interactions', typefilter: 'number', filtercomparator: 0 }, follower_count: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, profile_views: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, website_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, email_contacts: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, get_directions_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, phone_call_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, text_message_clicks: { name: 'Bereik', typefilter: 'number', filtercomparator: 0 }, };*/ //'page_fans','page_fans_country','page_fans_by_like_source','page_fans_gender_age' $scope.reportconfig = { page: { views: { insights: { name: 'insights', view: 'insights', first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, //second: { 'report': 'fans', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'impressions', metrics: ['impressions', 'reach', 'follower_count'], panels: { dataset: 'first' }, charts: { impressions: [ { 'chart': 'c1', 'name': 'Follower count', type: 'line', dataset: 'follower_count', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c2', 'name': 'Reach', type: 'line', dataset: 'reach', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c3', 'name': 'Impressions', type: 'line', dataset: 'impressions', date: true, 'dimension': 'date', 'metric': 'change' }, ], }, }, engagements: { name: 'Interactie', view: 'engagements', first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, //second: { 'report': 'engagements', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'profile_views', metrics: ['profile_views', 'website_clicks', 'email_contacts', 'get_directions_clicks', 'phone_call_clicks', 'text_message_clicks'], panels: { dataset: 'first' }, charts: { profile_views: [ { 'chart': 'c1', 'name': 'Profile views', type: 'line', dataset: 'profile_views', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c2', 'name': 'Website clicks', type: 'line', dataset: 'website_clicks', date: true, 'dimension': 'date', 'metric': 'change' }, { 'chart': 'c3', 'name': 'Email contacts', type: 'line', dataset: 'email_contacts', date: true, 'dimension': 'date', 'metric': 'change' }, ], }, }, audience: { name: 'Bereik', view: 'audience', //first: { 'report': 'timeline', 'endpoint': 'insights', 'dimension': 'date' }, second: { 'report': 'audience', 'endpoint': 'insights', 'dimension': 'date' }, selectedmetric: 'bereik', metrics: [], charts: { bereik: [ { 'chart': 'c1', 'name': 'Female audience / age ', type: 'bar', dataset: 'audience_gender_female_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c2', 'name': 'Male audience / age ', type: 'bar', dataset: 'audience_gender_male_age', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c3', 'name': 'Gender', type: 'pie', dataset: 'audience_gender', 'dimension': 'type', 'metric': 'val' }, ], }, }, posts: { name: 'Posts', view: 'posts', first: { 'report': 'posts' }, selectedmetric: 'posts', metrics: [], }, stories: { name: 'Stories', view: 'stories', first: { 'report': 'stories' }, selectedmetric: 'stories', metrics: [], }, }, }, } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller("linkedin", [ "$scope", "$timeout", "$http", "$rootScope", "$controller", "$filter", "h", function ($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: "https://app.pageowl.app/rest/linkedin/connect?websiteid=" + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: "", urlbase: "linkedin", report_endpoint: "/rest/linkedin/report", }; $scope.gloMetrics = h.metrics("linkedin"); // Added pageViews and uniqueVisitors to global metrics $scope.gloMetrics["pageViews"] = { typefilter: "number", filtercomparator: 0, datemerge: "sum" }; $scope.gloMetrics["uniqueVisitors"] = { typefilter: "number", filtercomparator: 0, datemerge: "sum" }; //'page_fans','page_fans_country','page_fans_by_like_source','page_fans_gender_age' $scope.reportconfig = { page: { views: { interactions: { name: "Interactions", view: "interactions", second: { dimension: "title", report: "followerstatistics-time" }, third: { dimension: "title", report: "sharestatistics" }, selectedmetric: "likeCount", metrics: ["likeCount", "shareCount", "engagement", "clickCount", "impressionCount", "commentCount"], panels: { dataset: "third" }, charts: { likeCount: [ { chart: "c0", name: "Likes / days", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", metric: "abc", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], shareCount: [ { chart: "c0", name: "Shares / Day", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], engagement: [ { chart: "c0", name: "Engagement / day", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], clickCount: [ { chart: "c0", name: "Clicks / day", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], impressionCount: [ { chart: "c0", name: "Impressions / day", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], commentCount: [ { chart: "c0", name: "Comments / day", type: "line", dataset: "third", dimension: "date" }, { chart: "c1", name: "New followers by type", type: "pie", dataset: "newfollowerdist", dimension: "key", metric: "val" }, { chart: "c2", dimension2: "type", name: "Lost and new followers", type: "linemulti", dataset: "newfollowerdelta", date: true, dimension: "date", metric: "value", graphs: ["Organic", "Paid"] }, ], }, }, network: { name: "Network", view: "network", first: { dimension: "title", report: "networksize" }, second: { dimension: "title", report: "followerstatistics" }, selectedmetric: "val", metrics: ["val"], table: { dataset: "region" }, charts: { val: [ { chart: "c0", name: "Followers / Staff size", type: "bar", dataset: "staffcount", dimension: "key", metric: "val" }, { chart: "c1", name: "Followers / Role", type: "pie", dataset: "seniority", dimension: "key", metric: "val" }, { chart: "c2", name: "Followers / Country", type: "progress", dataset: "country", dimension: "key", metric: "val" }, { chart: "c3", name: "Folowers / Industry", type: "progress", dataset: "industry", dimension: "key", metric: "val" }, { chart: "c4", name: "Folowers / Function", type: "progress", dataset: "function", dimension: "key", metric: "val" }, ], }, }, // NEW VIEW ADDED FOR VISITORS visitors: { hide: true, name: "Visitors", view: "visitors", first: { dimension: "title", report: "visitorstatistics" }, selectedmetric: "val", metrics: ["pageViews", "uniqueVisitors"], panels: { dataset: "first" }, table: { dataset: "country" }, charts: { val: [ { chart: "c0", name: "Visitors / Seniority", type: "pie", dataset: "seniority", dimension: "key", metric: "val" }, { chart: "c1", name: "Visitors / Industry", type: "bar", dataset: "industry", dimension: "key", metric: "val" }, { chart: "c2", name: "Visitors / Staff Size", type: "progress", dataset: "staffcount", dimension: "key", metric: "val" }, ], }, }, posts: { name: "Posts", view: "posts", first: { dimension: "title", report: "posts" }, selectedmetric: "Placeholder", dimension: "title", metrics: ["open_rate", "click_rate", "emails_sent", "unsubscribed", "bounces", "total_orders", "total_revenue"], panels: { dataset: "stats" }, charts: { Placeholder: [ { chart: "c0", name: "Emails sent", type: "line", dataset: "timeseries", metric: "emails_sent", dimension: "date" }, { chart: "c1", name: "Emails opened", type: "line", dataset: "timeseries", metric: "unique_opens", dimension: "date" }, { chart: "c2", name: "Clicks", type: "line", dataset: "timeseries", metric: "recipient_clicks", dimension: "date" }, ], }, }, personalposts: { hide: true, name: "Personal Posts", view: "personalposts", hide: true, first: { dimension: "title", report: "personalposts" }, selectedmetric: "Placeholder", dimension: "title", metrics: ["open_rate", "click_rate", "emails_sent", "unsubscribed", "bounces", "total_orders", "total_revenue"], panels: { dataset: "stats" }, charts: { Placeholder: [ { chart: "c0", name: "Emails sent", type: "line", dataset: "timeseries", metric: "emails_sent", dimension: "date" }, { chart: "c1", name: "Emails opened", type: "line", dataset: "timeseries", metric: "unique_opens", dimension: "date" }, { chart: "c2", name: "Clicks", type: "line", dataset: "timeseries", metric: "recipient_clicks", dimension: "date" }, ], }, }, }, }, }; $controller("coreapi", { $scope: $scope }); $scope.init(); }, ]); app.controller('linkedinxads', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.pageowl.app/rest/linkedinxads/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'linkedinxads', report_endpoint: '/rest/linkedinxads/report', } $scope.gloMetrics = h.metrics('linkedinxads'); /* $scope.gloMetrics = { impressions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, clicks: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, oneClickLeads: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, costInLocalCurrency: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, externalWebsiteConversions: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, costInLocalCurrency: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksize: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeexternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, networksizeinternal: { typefilter: 'number', filtercomparator: 0, 'datemerge': 'sum' }, }; */ //'page_fans','page_fans_country','page_fans_by_like_source','page_fans_gender_age' $scope.reportconfig = { campaigns: { views: { all: { name: 'Campagnes', view: 'all', first: { dimension: 'date', report: 'campaigns-time' }, second: { dimension: 'pivotValues', report: 'campaigns-all' }, selectedmetric: 'impressions' , selectedmetrics: { 'impressions': true, 'clicks': true, 'oneClickLeads': true, 'costInLocalCurrency': true, 'externalWebsiteConversions': true }, metrics: ['impressions', 'clicks', 'oneClickLeads','costInLocalCurrency','externalWebsiteConversions'], panels: { dataset: 'second' }, table: { dataset: 'second' }, charts: { impressions: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.impressions')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions')+' / Campaign', type: 'bar', dataset: 'second' }], clicks: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.clicks')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks')+' / Campaign', type: 'bar', dataset: 'second' }], oneClickLeads: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.clicks')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.clicks')+' / Campaign', type: 'bar', dataset: 'second' }], costInLocalCurrency: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.impressions')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.impressions')+' / Campaign', type: 'bar', dataset: 'second' }], externalWebsiteConversions: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpc')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpc')+' / Campaign', type: 'bar', dataset: 'second' }], cpm: [{ 'chart': 'c1', 'name': $filter('translate')('metrics.cpm')+' / Day', type: 'line', dataset: 'first', date: true }, { 'chart': 'c2', 'name': $filter('translate')('metrics.cpm')+' / Campaign', type: 'bar', dataset: 'second' }], }, }, network: { name: 'Campagnes', view: 'network', //first: { dimension: 'title', 'report': 'networksize' }, second: { dimension: 'title', 'report': 'followerstatistics' }, selectedmetric: 'val', metrics: ['val'], table: { dataset: 'region' }, charts: { val: [ { 'chart': 'c0', 'name': 'Followers / Staff size', type: 'bar', dataset: 'staffcount', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c1', 'name': 'Followers / Role', type: 'pie', dataset: 'seniority', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c2', 'name': 'Followers / Country', type: 'progress', dataset: 'country', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c3', 'name': 'Folowers / Industry', type: 'progress', dataset: 'industry', 'dimension': 'key', 'metric': 'val' }, { 'chart': 'c4', 'name': 'Folowers / Function', type: 'progress', dataset: 'function', 'dimension': 'key', 'metric': 'val' }, ], }, }, posts: { name: 'Campagne', view: 'posts', first: { dimension: 'title', 'report': 'posts', }, selectedmetric: 'Placeholder', dimension: 'title', metrics: ['open_rate', 'click_rate', 'emails_sent', 'unsubscribed', 'bounces', 'total_orders', 'total_revenue'], panels: { dataset: 'stats' }, charts: { Placeholder: [ { 'chart': 'c0', 'name': 'Emails sent', type: 'line', dataset: 'timeseries', metric: 'emails_sent', dimension: 'date' }, { 'chart': 'c1', 'name': 'Emails opened', type: 'line', dataset: 'timeseries', metric: 'unique_opens', dimension: 'date' }, { 'chart': 'c2', 'name': 'Clicks', type: 'line', dataset: 'timeseries', metric: 'recipient_clicks', dimension: 'date' }, ], }, }, personalposts: { hide: true, name: 'Campagne', view: 'personalposts', hide: true, first: { dimension: 'title', 'report': 'personalposts', }, selectedmetric: 'Placeholder', dimension: 'title', metrics: ['open_rate', 'click_rate', 'emails_sent', 'unsubscribed', 'bounces', 'total_orders', 'total_revenue'], panels: { dataset: 'stats' }, charts: { Placeholder: [ { 'chart': 'c0', 'name': 'Emails sent', type: 'line', dataset: 'timeseries', metric: 'emails_sent', dimension: 'date' }, { 'chart': 'c1', 'name': 'Emails opened', type: 'line', dataset: 'timeseries', metric: 'unique_opens', dimension: 'date' }, { 'chart': 'c2', 'name': 'Clicks', type: 'line', dataset: 'timeseries', metric: 'recipient_clicks', dimension: 'date' }, ], }, }, }, }, } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('mailchimp', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.marketingtracer.com/rest/mailchimp/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'mailchimp', report_endpoint: '/rest/mailchimp/report', } $scope.gloMetrics = h.metrics('mailchimp'); /* $scope.gloMetrics = { Placeholder: { name: '', typefilter: 'number', filtercomparator: 0 }, member_count: { name: 'Aanmeldingen', typefilter: 'number', filtercomparator: 0 }, list_rating: { name: 'Rating', typefilter: 'number', filtercomparator: 0 }, click_rate: { name: 'Klik Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, open_rate: { name: 'Open Ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, emails_sent: { name: 'Email verstuurd', typefilter: 'number', filtercomparator: 0 }, send_time: { name: 'Verstuurd op', typefilter: 'mcdate', filtercomparator: 0 }, unsubscribed: { name: 'Afmeldingen', typefilter: 'number', filtercomparator: 0 }, unsubscribe_count: { name: 'Afmeldingen', typefilter: 'number', filtercomparator: 0 }, cleaned_count: { name: 'Verwijderd', typefilter: 'number', filtercomparator: 0 }, bounces: { name: 'Bounce', typefilter: 'number', filtercomparator: 0 }, total_orders: { name: 'Orders', typefilter: 'number', filtercomparator: 0 }, recipient_clicks: { name: 'klikken', typefilter: 'number', filtercomparator: 0 }, total_revenue: { name: 'Totale opbrangest', typefilter: 'number', filtercomparator: 0, 'after': '€' }, }; */ //'page_fans','page_fans_country','page_fans_by_like_source','page_fans_gender_age' $scope.reportconfig = { mail: { views: { campaigns: { name: 'Campagnes', view: 'campaigns', second: { dimension: 'title', 'report': 'campaigns' }, selectedmetric: 'member_count', dimension: 'title', metrics: ['open_rate', 'click_rate', 'emails_sent', 'send_time', 'unsubscribed', 'bounces'], table: { dataset: 'second' }, charts: {}, }, campaign: { name: 'Campagne', view: 'campaign', hide: true, first: { dimension: 'title', 'report': 'campaign.sent_to', filterexpression: $scope.urlfilter }, second: { dimension: 'title', 'report': 'campaign', filterexpression: $scope.urlfilter }, selectedmetric: 'Placeholder', dimension: 'title', metrics: ['open_rate', 'click_rate', 'emails_sent', 'unsubscribed', 'bounces', 'total_orders', 'total_revenue'], panels: { dataset: 'stats' }, charts: { Placeholder: [ { 'chart': 'c0', 'name': 'Emails sent', type: 'line', dataset: 'timeseries', metric: 'emails_sent', dimension: 'date' }, { 'chart': 'c1', 'name': 'Emails opened', type: 'line', dataset: 'timeseries', metric: 'unique_opens', dimension: 'date' }, { 'chart': 'c2', 'name': 'Clicks', type: 'line', dataset: 'timeseries', metric: 'recipient_clicks', dimension: 'date' }, ], }, }, lists: { name: 'Lijsten', view: 'lists', second: { dimension: 'name', 'report': 'lists' }, selectedmetric: 'member_count', dimension: 'name', metrics: ['member_count', 'click_rate', 'open_rate', 'list_rating'], table: { dataset: 'second' }, charts: {}, }, list: { name: 'List', view: 'list', hide: true, first: { dimension: 'title', 'report': 'list', filterexpression: $scope.urlfilter }, second: { dimension: 'title', 'report': 'list.activity', filterexpression: $scope.urlfilter }, third: { dimension: 'title', 'report': 'list.members', filterexpression: $scope.urlfilter }, selectedmetric: 'Placeholder', dimension: 'title', metrics: ['open_rate', 'click_rate', 'member_count', 'unsubscribe_count', 'cleaned_count'], panels: { dataset: 'first' }, charts: { Placeholder: [ { 'chart': 'c0', 'name': 'Emails sent', type: 'line', dataset: 'second', metric: 'emails_sent', dimension: 'date' }, { 'chart': 'c1', 'name': 'Emails opened', type: 'line', dataset: 'second', metric: 'unique_opens', dimension: 'date' }, { 'chart': 'c2', 'name': 'Clicks', type: 'line', dataset: 'second', metric: 'recipient_clicks', dimension: 'date' }, ], }, }, }, }, } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('mybusiness', ['$scope', '$timeout', '$http', '$rootScope', '$controller', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $controller, $filter, h) { $scope.cfg = { authurl: 'https://app.marketingtracer.com/rest/mybusiness/connect?websiteid=' + h.siteId(), dateformat: "YYYY-MM-DD", dimensionprefix: '', urlbase: 'mybusiness', report_endpoint: '/rest/mybusiness/report', } $scope.gloMetrics = h.metrics('mybusiness'); /* $scope.gloMetrics = { Placeholder: { name: '', typefilter: 'number', filtercomparator: 0 }, QUERIES_DIRECT: { name: 'Directe zoekopdrachten', typefilter: 'number', filtercomparator: 0 }, QUERIES_INDIRECT: { name: 'Indirecte zoekopdrachten', typefilter: 'number', filtercomparator: 0 }, VIEWS_MAPS: { name: 'Google maps', typefilter: 'number', filtercomparator: 0 }, VIEWS_SEARCH: { name: 'Google zoeken', typefilter: 'number', filtercomparator: 0 }, QUERIES_DIRECT: { name: 'Bedrijf zoekopdacht', typefilter: 'number', filtercomparator: 0 }, QUERIES_INDIRECT: { name: 'Categorie zoekopdracht', typefilter: 'number', filtercomparator: 0 }, PHOTOS_VIEWS_MERCHANT: { name: 'Van eigenaars', typefilter: 'number', filtercomparator: 0 }, PHOTOS_VIEWS_CUSTOMERS: { name: 'Van bezoekers', typefilter: 'number', filtercomparator: 0 }, PHOTOS_COUNT_MERCHANT: { name: 'Van eigenaar', typefilter: 'number', filtercomparator: 0 }, PHOTOS_COUNT_CUSTOMERS: { name: 'Van bezoekers', typefilter: 'number', filtercomparator: 0 }, Date: { name: 'Datum', typefilter: 'number', filtercomparator: 0 }, AverageCpc: { name: 'Gemiddelde kosten per klik', typefilter: 'number', filtercomparator: 2, 'before': '€' }, Ctr: { name: 'Ctr', typefilter: 'number', filtercomparator: 0, 'after': '%' }, Conversions: { name: 'Conversions', typefilter: 'number', filtercomparator: 0 }, Cost: { name: 'Cost', typefilter: 'number', filtercomparator: 0, 'before': '€' }, CostPerConversion: { name: 'Kosten per conversie', typefilter: 'number', filtercomparator: 0, 'before': '€' }, ConversionRate: { name: 'Conversie ratio', typefilter: 'number', filtercomparator: 0, 'after': '%' }, AveragePosition: { name: 'Gemiddelde positie', typefilter: 'number', filtercomparator: 1 }, ConversionValue: { name: 'Totale conversiewaarde', typefilter: 'number', filtercomparator: 0, 'before': '€' }, }; */ $scope.fnFormatAd = function(d) { return d.HeadlinePart1 + d.HeadlinePart2 + d.Description + d.CreativeDestinationUrl + d.CreativeFinalUrls; } $scope.reportconfig = { business: { views: { all: { name: 'Overzicht', view: 'all', first: { report: 'total' }, second: { report: 'daily' }, selectedmetric: 'Placeholder', metrics: [], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { Placeholder: [ { 'chart': 'c1', 'name': 'Search type', type: 'bar', dataset: 'queries', 'dimension': 'type', 'metric': 'queries' }, { 'chart': 'c2', 'name': 'Searches / day', type: 'pie', type: 'line', dataset: 'second', metric: 'QUERIES', dimension: 'date' }, { 'chart': 'c3', 'name': 'Impressions / type', type: 'bar', dataset: 'views', 'dimension': 'type', 'metric': 'views' }, { 'chart': 'c4', 'name': 'Impressions / day', type: 'pie', type: 'line', dataset: 'second', metric: 'VIEWS', dimension: 'date' }, { 'chart': 'c5', 'name': 'Available Photo\'s', type: 'pie', dataset: 'photocount', 'dimension': 'type', 'metric': 'photocount' }, { 'chart': 'c6', 'name': 'Photo Impressions', type: 'pie', dataset: 'photoview', 'dimension': 'type', 'metric': 'photoview' }, { 'chart': 'c7', 'name': 'Website clicks', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_WEBSITE', dimension: 'date' }, { 'chart': 'c8', 'name': 'Phone contact', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_PHONE', dimension: 'date' }, { 'chart': 'c9', 'name': 'Driving Directions', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_DRIVING_DIRECTIONS', dimension: 'date' }, ], }, }, reviews: { name: 'Reviews', view: 'reviews', first: { report: 'reviews' }, selectedmetric: 'Placeholder', metrics: [], table: { dataset: 'second' }, panels: { dataset: 'second' }, charts: { Placeholder: [ { 'chart': 'c1', 'name': 'Search type', type: 'bar', dataset: 'queries', 'dimension': 'type', 'metric': 'queries' }, { 'chart': 'c2', 'name': 'Zoekopdrachten / day', type: 'pie', type: 'line', dataset: 'second', metric: 'QUERIES', dimension: 'date' }, { 'chart': 'c3', 'name': 'Impressions / type', type: 'bar', dataset: 'views', 'dimension': 'type', 'metric': 'views' }, { 'chart': 'c4', 'name': 'Impressions / day', type: 'pie', type: 'line', dataset: 'second', metric: 'VIEWS', dimension: 'date' }, { 'chart': 'c5', 'name': 'Photo\'s', type: 'pie', dataset: 'photocount', 'dimension': 'type', 'metric': 'photocount' }, { 'chart': 'c6', 'name': 'Photo Impressions', type: 'pie', dataset: 'photoview', 'dimension': 'type', 'metric': 'photoview' }, { 'chart': 'c7', 'name': 'Website clicks', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_WEBSITE', dimension: 'date' }, { 'chart': 'c8', 'name': 'Phone contact', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_PHONE', dimension: 'date' }, { 'chart': 'c9', 'name': 'Driving Directions', type: 'pie', type: 'line', dataset: 'second', metric: 'ACTIONS_DRIVING_DIRECTIONS', dimension: 'date' }, ], }, }, }, } } $controller('coreapi', { $scope: $scope }); $scope.init(); } ]); app.controller('keywordtool', ['$scope', '$http', '$rootScope', '$filter', '$location', 'h', function($scope, $http, $rootScope, $filter, $location, h) { $scope.suggest = { 'q': '', result: [] }; $scope.order = {}; $scope.fnOrder = function(field) { var reverse = (field == $scope.order.field) ? true : false; $scope.order = { 'field': field, 'reverse': reverse }; } var lang = { "de": [ { key: 'was', 'value': 'was ', 'type': 'vraag' }, { key: 'was ist', 'value': 'was ist ', 'type': 'vraag' }, { key: 'wie', 'value': 'wie ', 'type': 'vraag' }, { key: 'wann', 'value': 'wann ', 'type': 'vraag' }, { key: 'welche', 'value': 'welche ', 'type': 'vraag' }, { key: 'warum', 'value': 'warum ', 'type': 'vraag' }, ], "nl": [ { key: 'wat', 'value': 'wat ', 'type': 'vraag' }, { key: 'wat is', 'value': 'wat is ', 'type': 'vraag' }, { key: 'hoe', 'value': 'hoe ', 'type': 'vraag' }, { key: 'wanneer', 'value': 'wanneer ', 'type': 'vraag' }, { key: 'welke', 'value': 'welke ', 'type': 'vraag' }, { key: 'waarom', 'value': 'waarom ', 'type': 'vraag' }, ], "en": [ { key: 'what', 'value': 'what ', 'type': 'vraag' }, { key: 'what is', 'value': 'what is ', 'type': 'vraag' }, { key: 'how', 'value': 'how ', 'type': 'vraag' }, { key: 'when', 'value': 'when ', 'type': 'vraag' }, { key: 'which', 'value': 'which ', 'type': 'vraag' }, { key: 'why', 'value': 'why ', 'type': 'vraag' }, ] } var letters = [ { key: 'a', 'value': ' a' }, { key: 'b', 'value': ' b' }, { key: 'c', 'value': ' c' }, { key: 'd', 'value': ' d' }, { key: 'e', 'value': ' e' }, { key: 'f', 'value': ' f' }, { key: 'g', 'value': ' g' }, { key: 'h', 'value': ' h' }, { key: 'i', 'value': ' i' }, { key: 'j', 'value': ' j' }, { key: 'k', 'value': ' k' }, { key: 'l', 'value': ' l' }, { key: 'm', 'value': ' m' }, { key: 'n', 'value': ' n' }, { key: 'o', 'value': ' o' }, { key: 'p', 'value': ' p' }, { key: 'q', 'value': ' q' }, { key: 'r', 'value': ' r' }, { key: 's', 'value': ' s' }, { key: 't', 'value': ' t' }, { key: 'u', 'value': ' u' }, { key: 'v', 'value': ' v' }, { key: 'w', 'value': ' w' }, { key: 'x', 'value': ' x' }, { key: 'y', 'value': ' y' }, { key: 'z', 'value': ' z' }, ] $scope.letters = lang[$rootScope.user.site.locale.lang].concat(letters); var current = { 'key': '', value: '' } $scope.fnTrackKeyword = function(kw) { $http.post('/rest/rank-tracker/add', { keywords: kw, websiteid: h.siteId() }).then(function successCallback(res) { }); } $scope.fnExploreKeyword = function(q) { $scope.q = q; $scope.showsuggesties = true; $scope.suggest.result = []; current = { 'key': 'base', value: '' } $http.get('/rest/keywordtool/get', { params: { q: $scope.q, l: 'long', } }).then(function successCallback(res) { $scope.suggest.result['base'] = res.data; }); $scope.kwplanner = false; var next = false; var go = false; var mbreak = true; $http.get('/rest/googlexads/keywordplanner', { params: { q: $scope.q, cc: $scope.user.site.locale.cc, lc: $scope.user.site.locale.lc, } }).then(function successCallback(res) { $scope.kwplanner = res.data; }); angular.forEach($scope.letters, function(value, key) { if (value.type == 'vraag') { $http.get('/rest/keywordtool/get', { params: { q: value.value + $scope.q, cc: $scope.user.site.locale.cc, lc: $scope.user.site.locale.lc, } }).then(function successCallback(res) { if (res.data[1]) { $scope.suggest.result[value.key] = res.data; } }); } else { $http.get('/rest/keywordtool/get', { params: { q: $scope.q + value.value, cc: $scope.user.site.locale.cc, lc: $scope.user.site.locale.lc, } }).then(function successCallback(res) { if (res.data[1]) { $scope.suggest.result[value.key] = res.data; } }); } }); } $scope.fnDownload = function(text) { var text = ''; for (var i in $scope.suggest.result) { var res = $scope.suggest.result[i]; for (var y in res) { var woord = res[y]; if (woord.check) { console.log(woord); text = text + woord.r + "\r\n"; } } } for (var i in $scope.kwplanner) { if ($scope.kwplanner[i].check) { text = text + $scope.kwplanner[i].keyword + " (" + $scope.kwplanner[i].vol + "," + $scope.kwplanner[i].seo + "," + $scope.kwplanner[i].cpc + ")\r\n"; } } var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', 'zoekwoord suggesties voor ' + $scope.q + '.txt'); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } } ]); app.controller('onpagestatus', ['$scope', '$timeout', '$http', '$rootScope', '$location', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $location, $filter, h) { $scope.page = {}; var _cb = { getstarted: function() { $scope.page.include = 'index.getstarted'; }, crawling: function() { $scope.page.crawling = true; $scope.page.include = 'index.list'; }, ready: function() { $scope.page.include = 'index.list'; } } var timeout = 5000 var getStatus = function() { if ($rootScope.onpagestatus && $rootScope.onpagestatus.data.status == 'done') { _cb[$rootScope.onpagestatus.cb]($rootScope.onpagestatus.data) } else { $http.get('/rest/onpage/status', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $rootScope.onpagestatus = res.data; _cb[res.data.cb](res.data); if ($rootScope.onpagestatus && $rootScope.onpagestatus.data.status == 'crawling') { $scope.$broadcast('refreshonpagelist'); $timeout.cancel($rootScope.to); $rootScope.to = $timeout(function() { timeout = timeout * 1.2; getStatus(); }, timeout); } }); } } getStatus(); $scope.fnCrawl = function() { $http.get('/rest/onpage/crawl', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $rootScope.onpagestatus.data.status = 'crawling'; $timeout(function() { getStatus(); }, 2000); $location.path('/onpage-seo/all'); }); } $scope.fnEmpty = function() { $http.get('/rest/onpage/empty', { params: { websiteid: h.siteId(), } }); } } ]); app.filter('zeroplus', function() { return function(items, timingevent) { return items.reduce(function(acc, cur) { if (typeof cur.properties == 'object' && typeof cur.properties.timing == 'object' && cur.properties.timing[timingevent] > 0) { acc.push(cur); } return acc; }, []); }; }); app.controller('onpagelist', ['$scope', '$timeout', '$http', '$rootScope', '$filter', 'h', function($scope, $timeout, $http, $rootScope, $filter, h) { $scope.page = { 'curllabel': '', 'newurl': $rootScope.user.site.domain + '/' }; $scope.page.table = { 'curpage': 0, 'itemsperpage': 34, 'offset': 0, 'rulefilter': 'all', 'filter': {}, 'min': { 'opi': 100, 'score': 100 }, 'order': { 'k': 'url', 'r': false }, 'fnOrder': function(k, b) { this.order.r = (k == this.order.k) ? !this.order.r : b; this.order.k = k; }, fnSetOffset: function() { this.offset = this.itemsperpage * (this.curpage - 1); }, fnFilterProperty: function() { var pages = angular.copy($rootScope._pages); $scope.pages = $filter('filter')(pages, $scope.page.table.filter); } } $scope.fnPctChange = function(o, n) { if (n == 0) { return '0%'; } var pct = ((n - o) / o) * 100; var npct = $filter('number')(pct, 2); if(!isFinite(npct)){ npct = 100; } if (pct < 0) { return '' + npct + '%'; } return '+ ' + npct + '%';r } var fnSpeed = function(pages) { var t = {}; var s = {}; // min max pages.forEach(function(page, k) { if (typeof page.properties.timing != 'undefined' && page.properties.timing != null && page.properties.timing.ttfb) { var _otiming = page.properties.timing; delete _otiming.cls; Object.keys(_otiming).forEach(function(timingevent, n) { s[timingevent] = s[timingevent] || [] if (_otiming[timingevent] > 0) { s[timingevent].push(_otiming[timingevent]) } }) } }) for (timingevent in s) { var v = s[timingevent]; var min = 0; var max = Math.max(...v); var step = Math.round((max - min) / 18); if (step == 0) { step = 0.1; } var oinit = {} for (var i = min; i < max; i = i + step) { oinit[Math.round(i / step) * step] = 0; } t[timingevent] = v.reduce(function(acc, cur) { acc[Math.round(cur / step) * step] = acc[Math.round(cur / step) * step] + 1; return acc; }, oinit) } // get events from keys $scope.timingevents = Object.keys(t); var colors = h.colors() // create charts $scope.charts = {}; Object.keys(t).forEach(function(timingevent, k) { var data = Object.keys(t[timingevent]).map(function(a) { return { 'timing': a, 'pages': t[timingevent][a] } }) $scope.charts[timingevent] = { data: data, type: 'serial', categoryField: 'timing', rotate: false, legend: { enabled: false }, categoryAxis: { gridPosition: "start", parseDates: false, labelRotation: 0, gridThickness: 0, labelFunction: function(value, valueText, axis) { return value + 'ms' }, }, valueAxes: [{ position: "left", axisAlpha: 0, labelsEnabled: true, dashLength: 4, color: '#dedede', }], graphs: [{ fillColorsField: "color", balloonText: "" + timingevent + "
[[category]]ms:
[[value]] pages
", type: "column", columnWidth: 0.90, title: "Zoekwoord waarde", valueField: 'pages', fillAlphas: 1, lineColor: colors[k], autoColor: true, }], } }) } // AI-crawlers: status per bot voor de site en de pagina's met een blokkade $scope.oAiBots = { 'mode': 'crawlers', 'loading': false, 'data': null, init: function(refresh) { var self = this; self.loading = true; $http.get('/rest/onpage/aibots', { params: { websiteid: h.siteId(), refresh: refresh ? 1 : 0 } }).then(function successCallback(res) { self.data = res.data; self.rows = self.fnRows(res.data).filter(function(r) { return self.mode == 'crawlers' ? r.blocked.length > 0 : r.score !== null; }); self.loading = false; }, function errorCallback() { self.loading = false; }); }, fnBlocked: function(type) { return (this.data && this.data.bots || []).filter(function(b) { return b.type == type && (b.robots_pages > 0 || b.server_blocked); }); }, // één rij per pagina: AI-signalen en geblokkeerde AI-bots samen, zoals de paginatabel van de scan 'rows': [], 'table': { 'order': '-score', 'filter': { 'url': '' }, 'level': '', // paginering zoals de paginatabel van de scan 'curpage': 1, 'itemsperpage': 34, 'offset': 0, fnSetOffset: function() { this.offset = this.itemsperpage * (this.curpage - 1); }, fnReset: function() { this.curpage = 1; this.offset = 0; } }, fnRows: function(data) { var map = {}; var rows = []; var row = function(p) { var id = p._id.$oid; if (!map[id]) { map[id] = { '_id': p._id, 'url': p.url, 'score': null, 'level': null, 'top': [], 'blocked': [], 'search': false }; rows.push(map[id]); } return map[id]; }; ((data.aicontent && data.aicontent.pages) || []).forEach(function(p) { var r = row(p); r.score = p.score; r.level = p.level; r.top = p.top; }); (data.pages || []).forEach(function(p) { var r = row(p); r.blocked = p.blocked; r.search = p.search; }); return rows; }, fnOrder: function(key) { this.table.order = (this.table.order == '-' + key) ? key : '-' + key; this.table.fnReset(); }, fnLevel: function(r) { var level = $scope.oAiBots.table.level; return !level || r.level == level; }, // status van een bot in één woord: robots.txt, server of toegestaan fnBotStatus: function(b) { if (b.robots_home) { return 'robots_site'; } if (b.server_checked && this.data.conclusive && b.server_blocked) { return 'server'; } if (b.robots_pages > 0) { return 'robots_pages'; } return 'allowed'; } }; // calculare rules and scores var fnDuplicates = function(pages) { var duplicates = {}; for (var i in pages) { for (var y in pages[i]['compare']) { duplicates[y] = duplicates[y] || {}; duplicates[y][pages[i]['compare'][y]] = duplicates[y][pages[i]['compare'][y]] || { 'name': pages[i]['compare'][y], 'count': 0, 'pages': [] }; duplicates[y][pages[i]['compare'][y]].count++; duplicates[y][pages[i]['compare'][y]].pages.push({ 'serps': pages[i].serps, 'url': pages[i].url, 'properties': pages[i].properties, '_id': pages[i]._id }); } } // remove single counts in compare for (var i in duplicates) { for (var y in duplicates[i]) { if (duplicates[i][y].count <= 1) { delete duplicates[i][y]; } } } var x = {} for (var i in duplicates) { x[i] = Object.values(duplicates[i]) } $scope.duplicates = x; } // assistent var fnAssistent = function(pages) { $http.get('/rest/onpage/weights').then(function successCallback(weights) { var issues = []; for (var i in pages) { for (var y in pages[i]['rules']) { if (!pages[i]['rules'][y]) { // alleen regels die nog bestaan (geschrapte regels staan nog in oude scans, 2026-10-29) if (pages[i]['properties']['pagerank'] && pages[i]['properties']['opi'] && weights.data[y] && $scope.onpage && $scope.onpage.rules && $scope.onpage.rules[y]) { issues.push({ 'help': false, 'page': pages[i], 'weight': parseFloat(weights.data[y]['f'] * (200 + pages[i]['properties']['pagerank']) * (200 + pages[i]['properties']['opi'])), 'rule': y, 'f': weights.data[y]['f'] }) } } } } $scope.issues = issues; }); } // calculare rules and scores // welke view hoort bij het adres. Problemen, één probleem, Pagina's en Wijzigingen zijn de SEO-audit-overzichten // (index.audit.html, onpage.audit.js, 2026-10-29); die laden hun eigen tellingen en staan er meteen. var AUDIT = { rules: true, pages: true, changes: true }; $scope.auditView = function () { return !!AUDIT[$scope.view]; }; var fnView = function() { if ($scope.viewrule) { return 'pages'; } var other = { assistent: 'assistent', duplicates: 'duplicates', changes: 'changes', speed: 'speed', aibots: 'aibots', 'ai-detectie': 'aibots' }; return other[$scope.viewrulelabel] || 'rules'; }; var fnInit = function() { $scope.view = fnView(); if ($scope.view == 'assistent') { fnAssistent($rootScope._pages); } else if ($scope.view == 'duplicates') { fnDuplicates($rootScope._pages); } else if ($scope.view == 'speed') { fnSpeed($rootScope._pages); } else if ($scope.view == 'aibots') { // Arjen (2026-10-29): twee pagina's, AI-crawlers (blokkades) en AI-detectie (AI-signalen per pagina) $scope.oAiBots.mode = $scope.viewrulelabel == 'aibots' ? 'crawlers' : 'detection'; $scope.oAiBots.init(); } } $scope.$on('refreshonpagelist', function(e) { fnIndexList(); }); $scope.fnStar = function(curpage){ curpage.properties.star = (curpage.properties.star == 1)?0:1;; $http.post('/rest/onpage/page.save', { websiteid: h.siteId(), pageid: curpage._id.$oid, fields: { 'properties.star': curpage.properties.star } }); //$http.post("/rest/rank-tracker/edit", angular.extend({ 'meta.star': !ranking.meta.star, fields: ['meta.star'] }, fnSingle.getParams(ranking))).then(function successCallback(res) { $scope.getKeywords(); }) } // get list var fnIndexList = function() { $scope.page.table.fnOrder('properties.opi', true) //allways get rule list $http.get('/rest/onpage/rules', { 'params': { 'websiteid': h.siteId(), 'lang': $scope.user.site.locale.lang } }).then(function successCallback(res) { angular.forEach(res.data.rules, function(v, k) { v.enabled = !v.disabled; }) $scope.onpage = res.data; // get pages and historu of cache if ($rootScope._pages && $rootScope.onpagestatus.data.status == 'done') { $scope.pages = angular.copy($rootScope._pages); $scope.history = angular.copy($rootScope._history); fnInit(); } else { $http.get('/rest/onpage/list', { params: { websiteid: h.siteId(), } }).then(function successCallback(res) { $rootScope._pages = res.data.data; $rootScope._history = res.data.history; $scope.pages = angular.copy($rootScope._pages); $scope.history = angular.copy($rootScope._history); fnInit(); }); } }); } if ($scope.custominit) { $scope.viewrule = $scope.custominit.viewrule; $scope.viewrulelabel = $scope.custominit.viewrulelabel; }; $scope.view = fnView(); fnIndexList(); $scope.fnScanByUrl = function() { $scope.fnGoto('/rest/onpage/page.create?websiteid=' + h.siteId() + '&url=' + $scope.page.newurl); } } ]); // SEO-audit: de overzichtslijsten in de nieuwe stijl (2026-10-29): Problemen (/onpage-seo/all, per soort // /onpage-seo/
', link: function (scope) { scope.open = false; scope.domain = function () { return (($rootScope.user || {}).site || {}).domain || ''; }; scope.path = function (url) { return String(url || '').replace(/^https?:\/\/[^/]+/, '') || '/'; }; scope.cats = ['uptime', 'sitecheck', 'seo', 'cwv']; // waar je iets aan een open melding doet scope.fix = { uptime: '/uptime', sitecheck: '/onpage-seo/all', seo: '/onpage-seo/changes', cwv: '/crux' }; scope.dot = { uptime: 'bg-green-600', sitecheck: 'bg-amber-500', seo: 'bg-blue-600', cwv: 'bg-teal-600' }; // de rest van de laatste meldingen (wat nog speelt staat er al boven) scope.earlier = function () { return scope.d ? (scope.d.recent || []).filter(function (r) { return !r.open; }).slice(0, 5) : []; }; scope.d = null; var load = function () { mtAlerts.get().then(function (d) { scope.d = d; }, function () { scope.d = { open: [], recent: [] }; }); }; scope.tip = function () { var o = scope.d && scope.d.open ? scope.d.open : []; return o.length ? o.map(function (x) { return x.text; }).join(' · ') : $translate.instant('alerts.title'); }; // wat nog speelt eerst, daarna de rest van de laatste meldingen scope.list = function () { if (!scope.d) { return []; } var seen = {}; var out = []; (scope.d.open || []).concat(scope.d.recent || []).forEach(function (r) { var k = r.type + r.at; if (!seen[k]) { seen[k] = true; out.push(r); } }); return out; }; scope.when = function (ms, day) { return mtFmt.dayWeek(new Date(ms)) + (day ? '' : ' ' + new Date(ms).toLocaleTimeString($translate.use(), { hour: '2-digit', minute: '2-digit' })); }; scope.toggle = function () { scope.open = !scope.open; if (scope.open) { mtAlerts.clear(); load(); } }; scope.close = function () { scope.open = false; }; load(); scope.$on('$destroy', $rootScope.$on('$routeChangeSuccess', function () { scope.open = false; load(); })); } }; }]); // één regel op een pagina die meldt: "Je krijgt storingen van deze site meteen per mail. Instellen" (cat: uptime, // sitecheck, seo). Niets voor wie niet bij de site mag. app.directive('mtAlertLine', ['$translate', 'mtAlerts', function ($translate, mtAlerts) { return { restrict: 'E', scope: { cat: '@' }, template: '' + '{{text()}}{{\'alerts.line.set\' | translate}}', link: function (scope) { scope.text = function () { var t = scope.mine[scope.cat] || 'off'; var what = $translate.instant('alerts.line.cat.' + scope.cat); return t === 'off' ? $translate.instant('alerts.line.off', { what: what }) : $translate.instant('alerts.line.on', { what: what, when: scope.cat === 'cwv' ? $translate.instant('alerts.when.tuesday').toLowerCase() : $translate.instant('alerts.at.' + t) }); }; mtAlerts.get().then(function (d) { scope.mine = d.mine; }); } }; }]); // Korte verwijzing naar de firewallinstellingen, op elke pagina waar de MarketingTracer-bot geblokkeerd // kan worden (uptime, SEO-scan): . // De tekst noemt de tool ("onze uptime-monitor"), niet "MarketingTracer": in white label is dat niet de naam. // Altijd zichtbaar; de link alleen voor wie de instellingen mag zien (rol 5 of lager). app.directive('botFirewallRef', ['$rootScope', function ($rootScope) { return { restrict: 'E', scope: { blocked: '=?', tool: '@' }, template: '
' + '' + '' + '
', link: function (scope) { scope.canEdit = function () { return $rootScope.user && $rootScope.user.site && $rootScope.user.site.role < 6; }; } }; }]); // Site-instellingen (/settings/site#firewall): de geheime waarde van de site en de firewallregels voor // de MarketingTracer-bot (SEO-scan en uptime). Zie rest/include/bot.php. app.controller('botfirewall', ['$scope', '$http', '$location', '$anchorScroll', '$timeout', 'h', function ($scope, $http, $location, $anchorScroll, $timeout, h) { $scope.oFirewall = { data: null, revealed: false, copied: '', // Gemaskeerd tot je op "tonen" klikt: deze pagina wordt gedeeld en gescreenshot. fnMasked: function (token) { return token ? token.substring(0, 4) + '••••••••••••••••••••••' + token.substring(token.length - 4) : ''; }, fnMaskRule: function (rule) { var t = this.data && this.data.token; return t ? rule.split(t).join(this.fnMasked(t)) : rule; }, fnCopy: function (what, text) { var self = this; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(function () { $scope.$apply(function () { self.copied = what; }); }); } } }; $http.get('/rest/bot/firewall', { params: { websiteid: h.siteId() } }).then(function (res) { $scope.oFirewall.data = res.data; // link vanaf /uptime en de scan: /settings/site#firewall if ($location.hash() === 'firewall') { $timeout(function () { $anchorScroll('firewall'); }); } }); }]); // Wat bewaard blijft tussen de drie pagina's (/serp, /serp/keywords, /serp/competitors): de laatst opgehaalde // gegevens (direct tonen, op de achtergrond vernieuwen), ingeklapte groepen, en een filter dat een klik op het // overzicht meegeeft aan de zoekwoordenpagina. var serpState = { site: null, data: null, collapsed: {}, pending: null }; app.controller('serp', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', '$timeout', '$q', 'h', function ($scope, $rootScope, $location, $routeParams, $http, $translate, $timeout, $q, h) { // Rank tracker (SERP-tracker). Ontwerp: docs/superpowers/specs/2026-10-27-serp-tracker-design.md. Server: API v1, // /rest/v1/serp.* (rest/include/serp/api.php), dezelfde endpoints als klanten en de MCP; elk antwoord is {data, meta}. // fromApi zet dat om naar de vorm waarop het rekenwerk hieronder draait (reeksen naast de maanden). // De overzichtscijfers (zichtbaarheid, verdeling, stijgers) rekent de browser uit de posities per maand. // in een rapport (newlayout/serp/report.html): de maand van het rapport, alleen lezen var report = $scope.custominit && $scope.custominit.serp ? $scope.custominit : null; if (!report && (!$rootScope.user || !$rootScope.user.site || !$rootScope.user.site.show || !$rootScope.user.site.show.serp)) { $location.path('/'); return; } // CTR per positie (organisch, ruwe curve) voor de zichtbaarheid var CTR = [0, 0.28, 0.15, 0.10, 0.07, 0.05, 0.04, 0.03, 0.025, 0.02, 0.018, 0.012, 0.011, 0.010, 0.009, 0.008, 0.007, 0.006, 0.005, 0.005, 0.004]; var COLORS = { own: '#2563eb', own2: '#93c5fd', other: '#94a3b8', c: ['#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#64748b', '#ec4899'] }; var ROW = 24; var FREQS = ['monthly', 'biweekly', 'weekly', 'alternate', 'daily']; var KEYWORD_STEPS = [100, 150, 200, 250, 300, 400, 500, 750, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 7500, 10000]; var DEPTHS = [20, 30, 50, 100]; // zelfde lijst als serp::LOCATIONS (rest/include/serp/rules.php) var COUNTRIES = ['nl', 'be', 'de', 'uk', 'us', 'fr', 'at', 'ch', 'lu', 'it', 'es', 'pt', 'se', 'dk', 'fi', 'ie', 'pl', 'cz', 'sk', 'hu', 'ro', 'bg', 'hr', 'si', 'gr', 'tr', 'lv', 'lt', 'ee', 'ca', 'au', 'ae', 'jp', 'id']; function last(a) { return a[a.length - 1]; } // API v1: POST /rest/v1/serp. met JSON function api(part, body) { return $http.post('/rest/v1/serp.' + part, angular.extend({ site_id: h.siteId() }, body || {})).then(function (res) { return res.data; }); } function ym(m) { return String(m).replace('-', ''); } function msOf(iso) { return iso ? Date.parse(iso) : null; } // een eigen pagina als pad (zo toont de pagina hem, en zo staat hij in de top van een meting) function pathOf(u) { return String(u || '').replace(/^https?:\/\/[^/]+/, '') || '/'; } // [{month, position}] -> posities naast de maanden (Ym) function aligned(positions, months) { var by = {}; (positions || []).forEach(function (p) { by[ym(p.month)] = p.position; }); return months.map(function (m) { return by[m] === undefined ? null : by[m]; }); } // serp.summary, serp.keywords, serp.competitors (en serp.plan voor wie koopt) -> de gegevens van de pagina function fromApi(sum, kws, comp, plan) { var months = sum.months.map(ym); return { enabled: sum.measuring_enabled, trial: sum.trial, own: sum.own_domain, depth: sum.depth, months: months, report_month: sum.report_month ? ym(sum.report_month) : undefined, quota: sum.quota, last_measured_at: msOf(sum.last_measured_at), next_at: msOf(sum.next_measurement_at), can_plan: sum.can_set_plan, can_buy: sum.can_buy, paid_until: plan ? msOf(plan.paid_until) : null, competitors: comp ? comp.competitors : [], suggestions: comp ? comp.suggestions.map(function (x) { return { domain: x.domain, count: x.keywords }; }) : [], keywords: (kws || []).map(function (k) { var by = {}; k.positions.forEach(function (p) { by[ym(p.month)] = p; }); var urls = {}, cm = {}; k.pages.forEach(function (p) { urls[pathOf(p.url)] = aligned(p.positions, months); }); k.competitors.forEach(function (c) { cm[c.domain] = aligned(c.positions, months); }); return { id: k.id, q: k.keyword, cc: k.country, device: k.device, group: k.group || '', star: k.starred, target_url: k.target_url, vol: k.search_volume, state: k.state, paused: k.paused, urls: urls, comp: cm, series: months.map(function (m) { return by[m] ? by[m].position : null; }), measured: months.map(function (m) { return !!(by[m] && by[m].measured); }) }; }) }; } // serp.keyword -> de uitklap van één zoekwoord function detailFromApi(d) { var months = d.months.map(function (m) { return ym(m.month); }); var urls = {}, domains = {}; d.pages.forEach(function (p) { urls[pathOf(p.url)] = aligned(p.positions, months); }); d.domains.forEach(function (x) { domains[x.domain] = aligned(x.positions, months); }); return { at: msOf(d.measured_at), months: months, measured: d.months.map(function (m) { return m.measured; }), urls: urls, domains: domains, top: d.top.map(function (t) { return { pos: t.position, domain: t.domain, url: t.own ? pathOf(t.url) : t.url, own: t.own }; }) }; } // series: [{values:[positie|null], color, width, own}] -> svg-paden; positie 1 bovenaan (of omhoog als invert false) function lineChart(series, opts) { var w = opts.w, hgt = opts.h, n = opts.n, pad = opts.pad === undefined ? 4 : opts.pad, padX = opts.padX === undefined ? pad : opts.padX; var max = opts.max, min = opts.min, invert = opts.invert !== false; function x(i) { return n > 1 ? padX + i * (w - 2 * padX) / (n - 1) : w / 2; } function y(v) { var t = (v - min) / (max - min); return pad + (invert ? t : 1 - t) * (hgt - 2 * pad); } function smooth(pts) { var d = 'M' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1); for (var i = 0; i < pts.length - 1; i++) { var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; d += ' C' + [p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6, p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6, p2.x, p2.y].map(function (v) { return v.toFixed(1); }).join(' '); } return d; } return series.map(function (s) { var segs = [], cur = [], dots = []; (s.values || []).forEach(function (v, i) { if (v === null || v === undefined) { if (cur.length) { segs.push(cur); } cur = []; return; } var pt = { x: x(i), y: y(v), v: v }; cur.push(pt); dots.push(pt); }); if (cur.length) { segs.push(cur); } return { d: segs.map(smooth).join(' '), color: s.color, width: s.width || 2, own: s.own, dots: dots, last: last(dots), label: s.label, raw: s.raw || s.values }; }); } $scope.oSerp = { loading: true, data: null, rows: [], months: [], competitors: [], countries: COUNTRIES, colors: COLORS, freqs: FREQS, depths: DEPTHS, keywordSteps: KEYWORD_STEPS, ROW: ROW, rowsAxis: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], // de pagina komt uit het adres, zodat verversen je op dezelfde pagina laat // Arjen (2026-10-29): iedereen wil eerst de zoekwoorden, dus /serp is de zoekwoordenpagina; /serp/keywords blijft werken tab: report ? report.urlview : ({ inzichten: 'overview', overview: 'overview', keywords: 'keywords', competitors: 'competitors' }[$routeParams.tab] || 'keywords'), report: !!report, filter: { q: '', group: '', star: false, device: '', cannibal: false }, sort: 'pos', add: { show: false, text: '', cc: 'nl', device: 'desktop', group: '' }, bulk: { group: '' }, message: '', newCompetitor: '', settings: null, v: {}, init: function () { var self = this; // de stand, de zoekwoorden en de concurrenten tegelijk; wie een plan koopt ook het plan (betaald tot) var fetch = function (month) { var q = month ? { month: month } : {}; return $q.all([api('summary', q), api('keywords', angular.extend({ limit: 10000 }, q)), api('competitors')]).then(function (r) { var plan = r[0].data.can_buy && !month ? api('plan').then(function (p) { return p.data; }, function () { return null; }) : $q.resolve(null); return plan.then(function (p) { return fromApi(r[0].data, r[1].data, r[2].data, p); }); }); }; if (report) { // de maand van het rapport (reportdates.end in seconden), als YYYY-MM var end = new Date((($rootScope.reportdates || {}).end || Date.now() / 1000) * 1000); fetch(end.getFullYear() + '-' + ('0' + (end.getMonth() + 1)).slice(-2)).then(function (d) { self.load(d); }, function () { self.loading = false; self.failed = true; }); return; } if (serpState.site === h.siteId() && serpState.data && !self.data) { self.load(serpState.data); } fetch(null).then(function (d) { serpState.site = h.siteId(); serpState.data = d; self.load(d); }, function () { self.loading = false; }); }, // naar een andere pagina; filter of toevoegen gaat mee naar de zoekwoordenpagina fnGo: function (tab, pending) { serpState.pending = pending || null; $location.path({ keywords: '/serp', overview: '/serp/inzichten' }[tab] || '/serp/' + tab); }, load: function (d) { var self = this; var open = {}; angular.forEach(self.rows, function (r) { if (r.open) { open[r.id] = r.detail; } }); self.data = d; self.months = (d.months || []).map(function (ym) { return new Date(+ym.substr(0, 4), +ym.substr(4, 2), 0); }); self.competitors = d.competitors || []; self.rows = (d.keywords || []).map(function (k) { var urls = Object.keys(k.urls || {}).map(function (u) { return { url: u, series: k.urls[u] }; }); urls.sort(function (a, b) { var x = last(a.series), y = last(b.series); return (x === null ? 99 : x) - (y === null ? 99 : y); }); return angular.extend(k, { urls: urls, comp: k.comp || {}, open: !!open.hasOwnProperty(k.id), detail: open[k.id] || null, selected: false }); }); self.loading = false; self.refresh(); }, // alles wat ng-repeat toont en nieuw berekend wordt, één keer per wijziging (anders een eindeloze digest) refresh: function () { var self = this; angular.forEach(self.rows, function (r) { r._chart = null; r._serp = null; r._spark = null; r._urlRows = null; }); self.v = { summary: self.fnSummary(), groups: self.fnGroupStats(), comp: self.fnCompStats(), distribution: self.fnDistribution(), visChart: self.fnVisChart(), vs: self.fnVs(), up: self.fnMovers(true), down: self.fnMovers(false), anyMeasured: self.rows.some(function (r) { return self.fnLastIdx(r) > -1; }), // zoekwoorden die nog op hun nulmeting wachten (de cron verstuurt elke 5 minuten) waiting: self.rows.filter(function (r) { return self.fnLastIdx(r) === -1 && r.state !== 'error'; }).length, hasPrev: self.rows.some(function (r) { return self.fnPrev(r) !== undefined; }), // rapport, zoekwoorden: alles op positie reportRows: report ? self.fnFiltered() : null }; self.v.groupMap = {}; angular.forEach(self.fnGroupStats(true), function (g) { self.v.groupMap[g.name] = g; }); self.rowsVersion = (self.rowsVersion || 0) + 1; }, // --- posities per zoekwoord: alleen gemeten maanden tellen (null = niet in de top 20) fnLastIdx: function (r, before) { var m = r.measured || []; for (var i = (before === undefined ? m.length : before) - 1; i >= 0; i--) { if (m[i]) { return i; } } return -1; }, fnPos: function (r) { var i = this.fnLastIdx(r); return i < 0 ? undefined : r.series[i]; }, fnPrev: function (r) { var i = this.fnLastIdx(r); var j = i < 0 ? -1 : this.fnLastIdx(r, i); return j < 0 ? undefined : r.series[j]; }, fnWaiting: function (r) { return this.fnLastIdx(r) < 0; }, fnDiffOf: function (a, b) { if (a === undefined || b === undefined) { return 0; } return (a === null ? 21 : a) - (b === null ? 21 : b); }, fnDiff: function (r) { return this.fnDiffOf(this.fnPrev(r), this.fnPos(r)); }, // beste positie over de gemeten maanden; undefined als er nog niets gemeten is fnBest: function (r) { var b = this.fnLastIdx(r) < 0 ? undefined : null; r.series.forEach(function (p, i) { if (r.measured[i] && p !== null && (!b || p < b)) { b = p; } }); return b; }, fnUrlRows: function (r) { if (r._urlRows) { return r._urlRows; } var i = this.fnLastIdx(r); r._urlRows = r.urls.map(function (u) { return { url: u.url, pos: i < 0 ? null : u.series[i] }; }) .filter(function (u) { return u.pos !== null; }) .sort(function (a, b) { return a.pos - b.pos; }); return r._urlRows; }, fnCannibal: function (r) { return this.fnUrlRows(r).length > 1; }, fnTopUrl: function (r) { var u = this.fnUrlRows(r); return u.length ? u[0].url : null; }, fnSpark: function (r) { if (!r._spark) { var depth = this.data.depth || 20; var vals = r.series.map(function (p, i) { return r.measured[i] ? p : null; }); r._spark = lineChart([{ values: vals, color: COLORS.own, width: 1.5 }], { w: 80, h: 22, n: vals.length, min: 1, max: depth + 1 })[0]; } return r._spark; }, // --- uitklap: de top 20 van de laatste meting en de grafiek op dezelfde schaal (rij p = hoogte p) fnToggle: function (r) { r.open = !r.open; if (r.open && !r.detail) { api('keyword', { keyword_id: r.id }).then(function (res) { r.detail = detailFromApi(res.data); r._serp = null; r._chart = null; }); } }, fnSerp: function (r) { if (r._serp) { return r._serp; } if (!r.detail) { return []; } var self = this; var ownColor = {}; angular.forEach(r.urls, function (u, i) { ownColor[u.url] = i ? COLORS.own2 : COLORS.own; }); r._serp = (r.detail.top || []).slice(0, 20).map(function (s) { return angular.extend({}, s, { competitor: self.competitors.indexOf(s.domain), color: s.own ? (ownColor[s.url] || COLORS.own) : null }); }); return r._serp; }, fnDomainSeries: function (r, d) { if (r.detail && r.detail.domains && r.detail.domains[d]) { return r.detail.domains[d]; } return r.comp[d] || []; }, fnKeywordChart: function (r) { var self = this; r.pin = r.pin || {}; var show = Object.keys(r.pin).filter(function (d) { return r.pin[d]; }); if (r.hl && show.indexOf(r.hl) === -1 && r.hl !== self.data.own) { show.push(r.hl); } var key = show.join('|'); if (r._chart && r._chartKey === key) { return r._chart; } var measured = r.measured; // de grafiek loopt van 1 tot 20 (zoals de top 20 ernaast); dieper gemeten posities vallen eronder weg function onlyMeasured(vals) { return (vals || []).map(function (p, i) { return measured[i] && p !== null && p <= 20 ? p : null; }); } var series = show.map(function (d) { var ci = self.competitors.indexOf(d); var raw = self.fnDomainSeries(r, d); return { values: onlyMeasured(raw), raw: raw, label: d, color: ci > -1 ? COLORS.c[ci] : COLORS.other, width: 2 }; }); angular.forEach(r.urls, function (u, i) { series.push({ values: onlyMeasured(u.series), raw: u.series, label: u.url, color: i ? COLORS.own2 : COLORS.own, width: 3, own: true }); }); r._chart = lineChart(series, { w: 600, h: 20 * ROW, n: self.months.length, min: 0.5, max: 20.5, pad: 0, padX: 6 }); r._chartKey = key; return r._chart; }, fnHover: function (r, d) { r.hl = d; }, fnPin: function (r, d) { if (d === this.data.own) { return; } r.pin = r.pin || {}; r.pin[d] = !r.pin[d]; }, // --- lijsten fnFiltered: function () { var f = this.filter, q = (f.q || '').toLowerCase(), self = this, key = this.sort; return this.rows.filter(function (r) { return (!q || r.q.indexOf(q) > -1) && (!f.group || r.group === f.group) && (!f.star || r.star) && (!f.device || r.device === f.device) && (!f.cannibal || self.fnCannibal(r)); }).sort(function (a, b) { if (key === 'q') { return a.q.localeCompare(b.q); } if (key === 'diff') { return self.fnDiff(b) - self.fnDiff(a); } if (key === 'vol') { return (b.vol || 0) - (a.vol || 0); } var x = self.fnPos(a), y = self.fnPos(b); x = x === undefined ? 100 : (x === null ? 99 : x); y = y === undefined ? 100 : (y === null ? 99 : y); return x - y; }); }, // de gefilterde zoekwoorden per groep (alfabetisch, zonder groep als laatste); bewaard tot filter, sortering // of de zoekwoorden veranderen, anders telt ng-repeat elke digest nieuwe groepen collapsed: serpState.collapsed, fnGrouped: function () { var key = JSON.stringify(this.filter) + '|' + this.sort + '|' + this.rowsVersion; if (this._grouped && this._groupedKey === key) { return this._grouped; } var by = {}, order = []; angular.forEach(this.fnFiltered(), function (r) { var g = r.group || ''; if (!by[g]) { by[g] = { name: g, rows: [], selected: false }; order.push(g); } by[g].rows.push(r); }); order.sort(function (a, b) { return a === '' ? 1 : (b === '' ? -1 : a.localeCompare(b)); }); this._grouped = order.map(function (g) { return by[g]; }); this._groupedKey = key; return this._grouped; }, fnToggleGroup: function (name) { this.collapsed[name] = !this.collapsed[name]; }, fnSelectGroup: function (g, on) { angular.forEach(g.rows, function (r) { r.selected = on; }); }, fnGroups: function () { var g = {}; angular.forEach(this.rows, function (r) { if (r.group) { g[r.group] = true; } }); return Object.keys(g).sort(); }, fnMovers: function (up) { var self = this; return this.rows.filter(function (r) { return self.fnPrev(r) !== undefined && (up ? self.fnDiff(r) > 0 : self.fnDiff(r) < 0); }) .sort(function (a, b) { return up ? self.fnDiff(b) - self.fnDiff(a) : self.fnDiff(a) - self.fnDiff(b); }) .slice(0, 6); }, // --- zichtbaarheid: geschatte klikken / klikken als je overal #1 stond, gewogen naar volume (zonder volume: 1) fnVis: function (rows, domain, mi) { var self = this; rows = rows || self.rows; mi = mi === undefined ? self.months.length - 1 : mi; var got = 0, max = 0; angular.forEach(rows, function (r) { if (!r.measured[mi]) { return; } var vol = r.vol || 1; var p = (!domain || domain === self.data.own) ? r.series[mi] : (r.comp[domain] || [])[mi]; max += vol * CTR[1]; if (p !== null && p !== undefined) { got += vol * (CTR[p] || 0); } }); return max ? 100 * got / max : null; }, // Arjen (2026-10-29): een klik op een tegel zet die waarde in de grafiek per maand: zichtbaarheid (vis), aantal in // de top 10 (top10) of gemiddelde positie van wat in de top 20 staat (avg; 1 bovenaan); steeds met de concurrenten chartMetric: 'vis', fnPickMetric: function (m) { if (this.report) { return; } this.chartMetric = m; this.v.visChart = this.fnVisChart(); }, // zelfde stijl als een aanklikbare mt-kpi (js/ui.js); in een rapport gewoon een vlak fnTileClass: function (m) { if (this.report) { return 'bg-white border-gray-200'; } return this.chartMetric === m ? 'cursor-pointer bg-blue-50/40 border-blue-500 ring-1 ring-blue-500' : 'cursor-pointer bg-white border-gray-200 hover:border-blue-400 hover:shadow-md hover:-translate-y-px'; }, fnMonthStat: function (domain, mi) { var self = this; if (self.chartMetric === 'vis') { return self.fnVis(null, domain, mi); } var n = 0, sum = 0, top10 = 0, measured = 0; angular.forEach(self.rows, function (r) { if (!r.measured[mi]) { return; } measured++; var p = domain === self.data.own ? r.series[mi] : (r.comp[domain] || [])[mi]; if (p) { n++; sum += p; if (p <= 10) { top10++; } } }); if (!measured) { return null; } return self.chartMetric === 'top10' ? top10 : (n ? sum / n : null); }, fnVisChart: function () { var self = this, avg = self.chartMetric === 'avg'; var domains = [self.data.own].concat(self.competitors); var series = domains.map(function (d, i) { return { values: self.months.map(function (m, mi) { return self.fnMonthStat(d, mi); }), label: d, color: i ? COLORS.c[i - 1] : COLORS.own, width: i ? 2 : 3, own: i === 0 }; }); var max = 0; series.forEach(function (s) { s.values.forEach(function (v) { if (v > max) { max = v; } }); }); if (avg) { // positie 1 bovenaan, onderaan de slechtste positie (afgerond op 5, hooguit 20) self.visMax = Math.min(20, Math.ceil(max / 5) * 5) || 10; self.visAxis = ['1', self.fnNum((1 + self.visMax) / 2, self.visMax % 2 ? 0 : 1), String(self.visMax)]; return lineChart(series, { w: 640, h: 200, n: self.months.length, min: 1, max: self.visMax, invert: true }); } if (self.chartMetric === 'top10') { self.visMax = Math.ceil(max / 2) * 2 || 2; self.visAxis = [String(self.visMax), String(self.visMax / 2), '0']; } else { self.visMax = Math.ceil(max / 10) * 10 || 10; self.visAxis = [self.visMax + '%', self.visMax / 2 + '%', '0%']; } return lineChart(series, { w: 640, h: 200, n: self.months.length, min: 0, max: self.visMax, invert: false }); }, fnDistribution: function () { var self = this; return self.months.map(function (m, mi) { var d = { month: m, top3: 0, top10: 0, top20: 0, out: 0, total: 0 }; angular.forEach(self.rows, function (r) { if (!r.measured[mi]) { return; } var p = r.series[mi]; d.total++; if (p === null || p > 20) { d.out++; } else if (p <= 3) { d.top3++; } else if (p <= 10) { d.top10++; } else { d.top20++; } }); return d; }); }, // withEmpty: ook "zonder groep" (voor de koppen in de zoekwoordentabel) fnGroupStats: function (withEmpty) { var self = this; var names = self.fnGroups(); if (withEmpty && self.rows.some(function (r) { return !r.group; })) { names.push(''); } return names.map(function (g) { var rows = self.rows.filter(function (r) { return r.group === g; }); var n = 0, sum = 0, top10 = 0; angular.forEach(rows, function (r) { var p = self.fnPos(r); if (p) { n++; sum += p; if (p <= 10) { top10++; } } }); var vis = self.fnVis(rows), prev = self.fnVis(rows, null, self.months.length - 2); return { name: g, count: rows.length, vis: vis, diff: vis !== null && prev !== null ? vis - prev : null, avg: n ? sum / n : null, top10: top10 }; }); }, fnSummary: function () { var self = this, s = { top10: 0, top10prev: 0, n: 0, sum: 0, cannibal: 0 }; angular.forEach(self.rows, function (r) { var p = self.fnPos(r), pp = self.fnPrev(r); if (p) { s.n++; s.sum += p; if (p <= 10) { s.top10++; } } if (pp && pp <= 10) { s.top10prev++; } if (self.fnCannibal(r)) { s.cannibal++; } }); s.avg = s.n ? s.sum / s.n : null; s.vis = self.fnVis(null); var prev = self.fnVis(null, null, self.months.length - 2); s.visDiff = s.vis !== null && prev !== null ? s.vis - prev : null; return s; }, // --- concurrenten fnCompPos: function (r, d) { if (d === this.data.own) { return this.fnPos(r); } var i = this.fnLastIdx(r); return i < 0 ? undefined : (r.comp[d] || [])[i]; }, fnCompDiff: function (r, d) { if (d === this.data.own) { return this.fnDiff(r); } var i = this.fnLastIdx(r), j = i < 0 ? -1 : this.fnLastIdx(r, i); return j < 0 ? 0 : this.fnDiffOf((r.comp[d] || [])[j], (r.comp[d] || [])[i]); }, fnCompStats: function () { var self = this; return [self.data.own].concat(self.competitors).map(function (d, i) { var top10 = 0, n = 0, sum = 0; angular.forEach(self.rows, function (r) { var p = self.fnCompPos(r, d); if (p) { n++; sum += p; if (p <= 10) { top10++; } } }); var vis = self.fnVis(null, d), prev = self.fnVis(null, d, self.months.length - 2); return { domain: d, own: i === 0, color: i ? COLORS.c[i - 1] : COLORS.own, vis: vis, diff: vis !== null && prev !== null ? vis - prev : null, top10: top10, avg: n ? sum / n : null, found: n }; }); }, // per concurrent: op hoeveel zoekwoorden staat hij boven je fnVs: function () { var self = this, out = {}; angular.forEach(self.competitors, function (c) { var o = { you: 0, them: 0 }; angular.forEach(self.rows, function (r) { var a = self.fnPos(r), b = self.fnCompPos(r, c); if (a === undefined) { return; } a = a === null ? 99 : a; b = (b === null || b === undefined) ? 99 : b; if (a < b) { o.you++; } else if (b < a) { o.them++; } }); out[c] = o; }); return out; }, fnSaveCompetitors: function (list) { var self = this; $http.post('/rest/competitors/save', { websiteid: h.siteId(), competitors: list }).then(function () { self.newCompetitor = ''; self.init(); }); }, fnAddCompetitor: function (d) { if (d) { this.fnSaveCompetitors(this.competitors.concat([d])); } }, fnRemoveCompetitor: function (d) { this.fnSaveCompetitors(this.competitors.filter(function (c) { return c !== d; })); }, // --- bewerken (land of apparaat wijzigen en verwijderen tellen mee in het tegoed) fnSelected: function () { return this.rows.filter(function (r) { return r.selected; }); }, fnSelectAll: function (on) { angular.forEach(this.fnFiltered(), function (r) { r.selected = on; }); }, fnMessage: function (res) { var self = this, parts = []; var d = res.data || {}; if (d.error && d.error.code === 'trial') { parts.push($translate.instant('serp.msg.trial')); } if (d.added !== undefined) { parts.push($translate.instant('serp.msg.added', { n: d.added })); } if (d.exists) { parts.push($translate.instant('serp.msg.exists', { n: d.exists })); } if (d.over) { parts.push($translate.instant('serp.msg.over', { n: d.over })); } self.message = parts.join(' '); }, fnAdd: function () { var self = this; api('keywords_add', { keywords: self.add.text, country: self.add.cc, device: self.add.device, group: self.add.group }).then(function (res) { self.fnMessage({ data: res.data }); if (res.data.added) { self.add.text = ''; self.add.show = false; } self.init(); }, function (res) { self.fnMessage(res); }); }, fnEdit: function (ids, changes, reload) { var self = this; var body = { keyword_ids: ids }; if (changes.group !== undefined) { body.group = changes.group; } if (changes.star !== undefined) { body.starred = changes.star; } if (changes.target_url !== undefined) { body.target_url = changes.target_url; } return api('keywords_edit', body).then(function () { if (reload) { self.init(); } }); }, fnToggleStar: function (r) { r.star = !r.star; this.rowsVersion++; this.fnEdit([r.id], { star: r.star }, false); }, // in bulk alleen de groep (bestaand of nieuw); land en apparaat liggen vast zodra een zoekwoord bestaat fnBulkApply: function () { if (!this.bulk.group) { return; } this.fnEdit(this.fnSelected().map(function (r) { return r.id; }), { group: this.bulk.group }, true); this.bulk = { group: '' }; this.allSelected = false; }, fnBulkRemove: function () { var self = this; api('keywords_delete', { keyword_ids: self.fnSelected().map(function (r) { return r.id; }) }).then(function () { self.allSelected = false; self.init(); }); }, // CSV met puntkomma's en een BOM (Excel): positie per maand, de rankende pagina en het volume fnExport: function () { var self = this, d = self.data; var cell = function (v) { v = v === null || v === undefined ? '' : String(v); return /[;"\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }; var lines = [['zoekwoord', 'groep', 'land', 'apparaat'].concat(d.months, ['pagina', 'volume']).map(cell).join(';')]; angular.forEach(self.rows, function (r) { var page = ''; angular.forEach(r.urls, function (u) { if (last(u.series) !== null) { page = u.url; } }); lines.push([r.q, r.group, r.cc, r.device].concat(r.series.map(function (p, i) { return r.measured[i] && p !== null ? p : ''; }), [page, r.vol]).map(cell).join(';')); }); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })); a.download = 'posities-' + (d.own || 'site') + '-' + new Date().toISOString().slice(0, 10) + '.csv'; document.body.appendChild(a); a.click(); a.remove(); }, // --- meetinstellingen (alleen wie plannen mag instellen); de prijs komt van de server fnOpenSettings: function () { var self = this; api('plan').then(function (res) { var p = res.data.plan; self.settings = { own: res.data.own_plan, buy: !!res.data.buy, now: res.data.pay_now_eur, freq: Math.max(0, FREQS.indexOf(p.freq)), kw: Math.max(0, KEYWORD_STEPS.indexOf(p.keywords) > -1 ? KEYWORD_STEPS.indexOf(p.keywords) : KEYWORD_STEPS.filter(function (k) { return k <= p.keywords; }).length - 1), depth: Math.max(0, DEPTHS.indexOf(p.depth)), price: res.data.price_eur }; }); }, fnSettingsPlan: function () { var s = this.settings; return { freq: FREQS[+s.freq], keywords: KEYWORD_STEPS[+s.kw], depth: DEPTHS[+s.depth] }; }, fnSettingsChanged: function () { var self = this; if (self._priceTimer) { $timeout.cancel(self._priceTimer); } self._priceTimer = $timeout(function () { api('plan_price', self.fnSettingsPlan()).then(function (res) { if (self.settings) { self.settings.price = res.data.price_eur; self.settings.now = res.data.pay_now_eur; } }); }, 250); }, // directe klant: kopen. Naar Mollie als er nu iets te betalen is, anders staat het plan meteen aan. fnBuy: function () { var self = this; self.settings.busy = true; api('buy', { plan: self.fnSettingsPlan() }).then(function (res) { if (res.data.checkout_url) { window.location.href = res.data.checkout_url; return; } self.settings = null; self.init(); }, function () { self.settings.busy = false; }); }, fnSaveSettings: function (followDefault) { var self = this; api('plan_save', followDefault ? {} : { plan: self.fnSettingsPlan() }).then(function () { self.settings = null; self.init(); }); }, // --- opmaak fnMonth: function (d) { return d.toLocaleDateString($translate.use() || 'nl', { month: 'short' }).replace('.', ''); }, fnDate: function (ms) { return ms ? new Date(ms).toLocaleDateString($translate.use() || 'nl', { day: 'numeric', month: 'short' }) : ''; }, fnNum: function (v, digits) { return v === null || v === undefined ? '–' : (+v).toLocaleString($translate.use() || 'nl', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); }, fnEuro: function (v) { return '€' + (+v).toLocaleString($translate.use() || 'nl', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }, // --- tooltips: één zwevend kader bij de muis, in de stijl van de andere grafieken (donker) tip: null, _bands: {}, // per maand een strook over de grafiek waarop de muis de tooltip opent (zelfde x als de punten) fnBands: function (n, w, padX) { var key = n + '|' + w + '|' + padX; if (!this._bands[key]) { var step = n > 1 ? (w - 2 * padX) / (n - 1) : w; var out = []; for (var i = 0; i < n; i++) { var cx = n > 1 ? padX + i * step : w / 2; var left = Math.max(0, cx - step / 2), right = Math.min(w, cx + step / 2); out.push({ i: i, left: 100 * left / w, width: 100 * (right - left) / w, center: 100 * cx / w }); } this._bands[key] = out; } return this._bands[key]; }, fnMonthLong: function (i) { var d = this.months[i]; if (!d) { return ''; } var opts = { month: 'long' }; if (d.getFullYear() !== new Date().getFullYear()) { opts.year = 'numeric'; } return d.toLocaleDateString($translate.use() || 'nl', opts); }, fnTipAt: function ($event, kind, i, title, rows) { var x = $event.clientX, y = $event.clientY; this.tip = { kind: kind, i: i, title: title, rows: rows, left: x + 240 > window.innerWidth ? x - 236 : x + 14, top: y + 14 }; }, fnTipOff: function () { this.tip = null; }, fnVisTip: function ($event, i) { var self = this; var rows = self.v.visChart.map(function (p) { var v = p.raw[i]; var txt = self.chartMetric === 'top10' ? String(v) : self.fnNum(v, 1) + (self.chartMetric === 'vis' ? '%' : ''); return { color: p.color, label: p.label, value: v === null || v === undefined ? '–' : txt, strong: p.own }; }); self.fnTipAt($event, 'vis', i, self.fnMonthLong(i), rows); }, fnDistTip: function ($event, d, i) { var t = function (k) { return $translate.instant(k); }; this.fnTipAt($event, 'dist', i, this.fnMonthLong(i), d.total ? [ { color: '#1d4ed8', label: 'top 3', value: d.top3 }, { color: '#60a5fa', label: '4-10', value: d.top10 }, { color: '#bfdbfe', label: '11-20', value: d.top20 }, { color: '#e5e7eb', label: '> 20', value: d.out } ] : [{ label: t('serp.not_measured'), value: '' }]); }, fnSparkTip: function ($event, r) { var self = this, rows = []; r.series.forEach(function (p, i) { if (r.measured[i]) { rows.push({ label: self.fnMonth(self.months[i]), value: self.fnPosLabel(p) }); } }); if (!rows.length) { rows.push({ label: $translate.instant('serp.waiting'), value: '' }); } self.fnTipAt($event, 'spark', null, r.q, rows); }, fnDetailTip: function ($event, r, i) { var self = this; var rows = self.fnKeywordChart(r).slice().sort(function (a, b) { return (b.own ? 1 : 0) - (a.own ? 1 : 0); }).map(function (p) { return { color: p.color, label: p.label, value: r.measured[i] ? self.fnPosLabel((p.raw || [])[i]) : '–', strong: p.own }; }); self.fnTipAt($event, 'detail', i, self.fnMonthLong(i) + (r.measured[i] ? '' : ' · ' + $translate.instant('serp.not_measured')), rows); }, // landnaam in de taal van de gebruiker (uk = Verenigd Koninkrijk) fnCountry: function (c) { try { return new Intl.DisplayNames([$translate.use() || 'nl'], { type: 'region' }).of(c === 'uk' ? 'GB' : c.toUpperCase()); } catch (e) { return c.toUpperCase(); } }, fnDaysUntil: function (ms) { return Math.max(0, Math.ceil((ms - Date.now()) / 86400000)); }, fnPosLabel: function (p) { return p === undefined ? '–' : (p === null ? '> ' + ((this.data && this.data.depth) || 20) : p); } }; // een tooltip hoort bij wat je ziet: weg bij een andere tab (mouseleave komt niet als het element verdwijnt) $scope.$watch('oSerp.tab', function () { $scope.oSerp.tip = null; }); // een klik op het overzicht (groep, interne concurrentie, toevoegen) zet het filter op de zoekwoordenpagina if (serpState.pending && $scope.oSerp.tab === 'keywords') { var p = serpState.pending; serpState.pending = null; if (p.group !== undefined) { $scope.oSerp.filter.group = p.group; } if (p.cannibal) { $scope.oSerp.filter.cannibal = true; } if (p.add) { $scope.oSerp.add.show = true; } } // een link vanaf het dashboard: /serp/keywords?q= filtert op dat zoekwoord if (!report && $scope.oSerp.tab === 'keywords' && $location.search().q) { $scope.oSerp.filter.q = String($location.search().q).toLowerCase(); } // terug van Mollie: de webhook verwerkt de betaling, meestal binnen een paar seconden. Tot 30 seconden de status van // de bestelling opvragen en zeggen wat er echt gebeurde. if (!report && $location.search().betaald) { var orderId = $location.search().betaald, tries = 0; $location.search('betaald', null); $scope.oSerp.paidMessage = 'serp.paid_wait'; var check = function () { api('order', { order_id: orderId }).then(function (res) { var st = res.data.status; if (st === 'paid') { $scope.oSerp.paidMessage = 'serp.paid_ok'; $scope.oSerp.init(); } else if (st && st !== 'open') { $scope.oSerp.paidMessage = 'serp.paid_failed'; } else if (++tries < 15) { return $timeout(check, 2000); } $timeout(function () { $scope.oSerp.paidMessage = null; }, 8000); }); }; check(); } $scope.oSerp.init(); }]); // Bouwblokken voor de nieuwe pagina's (Posities in Google, Search Console Insights, en wat daarna komt). // Wat ze doen, hoe ze eruitzien en wanneer je welke gebruikt: docs/ui.md. Nieuwe pagina's gebruiken deze blokken // in plaats van eigen kaarten, grafieken en tooltips. // // Regels die overal gelden: // - Grafieken krijgen een nieuw data-object als de gegevens veranderen; de blokken kijken naar de verwijzing, // niet naar de inhoud (geen dure deep watch, geen eindeloze digest). // - Teksten komen vertaald binnen (title="{{'sci.x' | translate}}"); de blokken vertalen alleen hun eigen labels (ui.*). // --- glimmer over placeholders (mt-shim): een lichte baan die van links naar rechts loopt, zodat je ziet dat er iets // laadt. Eén keer in de pagina gezet. (function () { if (document.getElementById('mt-ui-css')) { return; } var st = document.createElement('style'); st.id = 'mt-ui-css'; st.textContent = '@keyframes mt-shim{0%{background-position:-400px 0}100%{background-position:400px 0}}' + '.mt-shim{background:linear-gradient(90deg,#f3f4f6 0%,#e5e7eb 40%,#f3f4f6 80%);background-size:800px 100%;animation:mt-shim 1.4s linear infinite}' + // kaarten en tegels zijn blokken: anders werken tussenruimtes (space-y, mt-*) niet als ze onder elkaar staan 'mt-card,mt-kpi,mt-state{display:block}'; document.head.appendChild(st); })(); // --- notatie: één plek voor getallen, procenten, euro's, maanden en verschillen app.factory('mtFmt', ['$translate', function ($translate) { function lang() { return $translate.use() || 'nl'; } function num(v, digits) { if (v === null || v === undefined || v === '' || isNaN(v)) { return '–'; } return (+v).toLocaleString(lang(), { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); } // 'YYYY-MM' of 'YYYY-MM-DD' of een Date function date(d) { if (d instanceof Date) { return d; } if (typeof d === 'number') { return new Date(d); } var p = String(d).split('-'); return new Date(+p[0], (+p[1] || 1) - 1, +p[2] || 1); } return { num: num, // grote getallen kort: 12.400 -> 12,4K vanaf 10.000 short: function (v) { if (v === null || v === undefined || isNaN(v)) { return '–'; } var a = Math.abs(v); if (a >= 1e6) { return num(v / 1e6, 1) + 'M'; } if (a >= 1e4) { return num(v / 1e3, 1) + 'K'; } return num(v); }, pct: function (v, digits) { return v === null || v === undefined || isNaN(v) ? '–' : num(v, digits === undefined ? 1 : digits) + '%'; }, // euro's: hele euro's vanaf 100, anders twee decimalen euro: function (v) { if (v === null || v === undefined || isNaN(v)) { return '–'; } return '€ ' + num(v, Math.abs(v) >= 100 ? 0 : 2); }, pos: function (v) { return v === null || v === undefined || isNaN(v) || v <= 0 ? '–' : num(v, 1); }, month: function (d) { return date(d).toLocaleDateString(lang(), { month: 'short' }).replace('.', ''); }, // "oktober" dit jaar, "oktober 2025" een ander jaar monthLong: function (d) { d = date(d); var o = { month: 'long' }; if (d.getFullYear() !== new Date().getFullYear()) { o.year = 'numeric'; } return d.toLocaleDateString(lang(), o); }, // één datum in een zin ("sinds 27 okt") of een week; nooit in een tijdlijn per dag day: function (d) { return date(d).toLocaleDateString(lang(), { day: 'numeric', month: 'short' }); }, // met de dag van de week: "wo 27 okt". Elke tijdlijn per dag (as, tooltip, tabel per datum) gebruikt deze // (Arjen 2026-11-01: "altijd dag") dayWeek: function (d) { return date(d).toLocaleDateString(lang(), { weekday: 'short', day: 'numeric', month: 'short' }).replace(/\./g, ''); }, // procentuele verandering, null als er niets te vergelijken is change: function (now, before) { if (before === null || before === undefined || now === null || now === undefined || !before) { return null; } return 100 * (now - before) / before; } }; }]); // --- één zwevende tooltip voor alle grafieken (donker, bij de muis); los van de digest app.factory('mtTip', function () { var el = null; function esc(s) { return String(s === null || s === undefined ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } return { // rows: [{label, value, color?, strong?}] show: function (e, title, rows) { if (!el) { el = document.createElement('div'); el.className = 'fixed z-50 pointer-events-none rounded-md bg-gray-900/90 text-white text-xs shadow-lg px-3 py-2 min-w-[140px] max-w-[280px]'; document.body.appendChild(el); } var html = title ? '
' + esc(title) + '
' : ''; (rows || []).forEach(function (r) { html += '
' + (r.color ? '' : '') + '' + esc(r.label) + '' + esc(r.value) + '
'; }); el.innerHTML = html; el.style.display = 'block'; var x = e.clientX, y = e.clientY; el.style.left = (x + 290 > window.innerWidth ? x - 286 : x + 14) + 'px'; el.style.top = (y + 14) + 'px'; }, hide: function () { if (el) { el.style.display = 'none'; } } }; }); // --- geometrie voor de grafieken (los te testen, geen DOM) app.factory('mtChart', function () { // een rond maximum voor de as: 0, de helft en dit getal function niceMax(v) { if (!v || v <= 0) { return 1; } var p = Math.pow(10, Math.floor(Math.log10(v))); var steps = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; for (var i = 0; i < steps.length; i++) { if (steps[i] * p >= v) { return steps[i] * p; } } return 10 * p; } // per punt een strook over de grafiek waarop de muis de tooltip opent (zelfde x als de punten), in % function bands(n, w, padX) { var step = n > 1 ? (w - 2 * padX) / (n - 1) : w, out = []; for (var i = 0; i < n; i++) { var cx = n > 1 ? padX + i * step : w / 2; var left = Math.max(0, cx - step / 2), right = Math.min(w, cx + step / 2); out.push({ i: i, left: 100 * left / w, width: 100 * (right - left) / w, center: 100 * cx / w }); } return out; } // monotone curve (Arjen 2026-10-28: de lijn schoot boven de top en onder de 0): glad, maar nooit voorbij het // hoogste of laagste punt. pts: [{x, y}] met oplopende x function smooth(pts) { var n = pts.length, t = [], s = [], h = [], i; var d = 'M' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1); if (n < 2) { return d; } for (i = 0; i < n - 1; i++) { h[i] = pts[i + 1].x - pts[i].x; s[i] = (pts[i + 1].y - pts[i].y) / (h[i] || 1); } for (i = 1; i < n - 1; i++) { var p = (s[i - 1] * h[i] + s[i] * h[i - 1]) / ((h[i - 1] + h[i]) || 1); t[i] = (Math.sign(s[i - 1]) + Math.sign(s[i])) * Math.min(Math.abs(s[i - 1]), Math.abs(s[i]), 0.5 * Math.abs(p)) || 0; } t[0] = n > 2 ? (3 * s[0] - t[1]) / 2 : s[0]; t[n - 1] = n > 2 ? (3 * s[n - 2] - t[n - 2]) / 2 : s[0]; if (Math.sign(t[0]) !== Math.sign(s[0])) { t[0] = 0; } if (Math.sign(t[n - 1]) !== Math.sign(s[n - 2])) { t[n - 1] = 0; } for (i = 0; i < n - 1; i++) { var dx = h[i] / 3; d += ' C' + [pts[i].x + dx, pts[i].y + dx * t[i], pts[i + 1].x - dx, pts[i + 1].y - dx * t[i + 1], pts[i + 1].x, pts[i + 1].y].map(function (v) { return v.toFixed(1); }).join(' '); } return d; } // series [{values:[getal|null], connect?, nodots?}] -> svg-paden met losse stukken waar data ontbreekt, een dunne // stippellijn over die gaten (bridge) en de punten. connect: over lege waarden heen tekenen (losse metingen); // nodots: alleen een punt waar een stuk uit één punt bestaat function lines(series, o) { var w = o.w, h = o.h, n = o.n, pad = o.pad === undefined ? 6 : o.pad, padX = o.padX === undefined ? pad : o.padX; function x(i) { return n > 1 ? padX + i * (w - 2 * padX) / (n - 1) : w / 2; } function y(v) { var t = (v - o.min) / ((o.max - o.min) || 1); return pad + (o.invert ? t : 1 - t) * (h - 2 * pad); } return series.map(function (s) { var segs = [], cur = [], dots = []; (s.values || []).forEach(function (v, i) { if (v === null || v === undefined || isNaN(v)) { if (cur.length && !s.connect) { segs.push(cur); cur = []; } return; } var pt = { x: x(i), y: y(v), px: 100 * x(i) / w, py: 100 * y(v) / h }; cur.push(pt); dots.push(pt); }); if (cur.length) { segs.push(cur); } var bridge = []; for (var j = 1; j < segs.length; j++) { var a = segs[j - 1][segs[j - 1].length - 1], b = segs[j][0]; bridge.push('M' + a.x.toFixed(1) + ' ' + a.y.toFixed(1) + ' L' + b.x.toFixed(1) + ' ' + b.y.toFixed(1)); } if (s.nodots) { dots = segs.filter(function (g) { return g.length === 1; }).map(function (g) { return g[0]; }); } return { d: segs.map(smooth).join(' '), bridge: bridge.join(' '), color: s.color, width: s.width || 2, dash: s.dash ? '4 4' : '', dots: dots, label: s.label }; }); } // as voor hele getallen (klikken, vertoningen): minstens tot 2 en altijd een heel midden function niceInt(v) { if (!v || v <= 2) { return 2; } var p = Math.pow(10, Math.floor(Math.log10(v))); var steps = [2, 4, 6, 8, 10]; for (var i = 0; i < steps.length; i++) { if (steps[i] * p >= v) { return steps[i] * p; } } return 10 * p; } return { niceMax: niceMax, niceInt: niceInt, bands: bands, lines: lines, smooth: smooth }; }); // --- kaart: wit, rand, titel met eventueel een link rechts, een uitlegregel, en de inhoud. // loading: de inhoud is er nog niet; dan grijze balken op de plek (minh = hoogte in px), zodat niets verspringt. app.directive('mtCard', function () { return { restrict: 'E', transclude: true, scope: { title: '@', sub: '@', more: '@', moreHref: '@', loading: '' + '
' + '

{{title}}

' + '{{more}} →
' + '

{{sub}}

' + '
' + '
' + '
', // title is ook een HTML-attribuut: dan toont de browser de kaartnaam als tooltip waar je ook in de kaart staat link: function (scope, el, attrs) { attrs.$observe('title', function () { el[0].removeAttribute('title'); }); } }; }); // --- verschil: ▲ groen of ▼ rood; invert voor posities (lager is beter); unit '%' (standaard, ook leeg), 'pt' of 'none' // up / down: een woord achter het getal ("▼ 30% sneller", "▲ 1% trager"); zero: tekst bij geen verschil ("gelijk") app.directive('mtDelta', ['mtFmt', function (mtFmt) { return { restrict: 'E', scope: { value: '<', invert: '' + ' {{v === 0 && zero ? zero : txt}} {{v > 0 ? up : down}}', link: function (scope) { scope.$watch('value', function (val) { scope.v = val === null || val === undefined || isNaN(val) ? null : +val; if (scope.v === null) { return; } var unit = !scope.unit || scope.unit === '%' ? '%' : (scope.unit === 'none' ? '' : ' ' + scope.unit); var d = scope.digits === undefined || scope.digits === '' ? (unit === '%' ? 0 : 1) : +scope.digits; if (Math.abs(scope.v) < Math.pow(10, -d) / 2) { scope.v = 0; } scope.good = scope.invert ? scope.v < 0 : scope.v > 0; scope.txt = mtFmt.num(Math.abs(scope.v), d) + unit; }); } }; }]); // --- cijfertegel: label, groot getal, verschil, een regel uitleg; met href is de hele tegel een link app.directive('mtKpi', function () { return { restrict: 'E', // pick: klikbaar (bv. de grafiek eronder wisselen), pickTitle: wat de klik doet, active: de gekozen tegel // tone: een stip voor het getal in de kleur van een oordeel (good | ni | poor | none), bv. Core Web Vitals // mark: een rond icoon voor het getal (good = vinkje, poor = kruis, none = streepje), bv. geslaagd of niet; // suffix: kleiner en grijs achter het getal ("#1 van 4"); zero: tekst bij een verschil van 0 ("gelijk") scope: { label: '@', value: '@', delta: '' + '' + '
', link: function (scope) { scope.hasDelta = function () { return scope.delta !== null && scope.delta !== undefined; }; } }; }); app.run(['$templateCache', function ($templateCache) { $templateCache.put('mt-kpi-body', '
{{label}}
' + '
' + '
' + '' + '' + '' + '{{value}}' + '{{suffix}}' + '
' + '
{{note}}
'); }]); // --- tabs bovenaan een pagina; elke tab is een adres, zodat verversen je op dezelfde pagina laat // tabs: [{key, label, href, count?}] app.directive('mtTabs', function () { return { restrict: 'E', scope: { tabs: '<', active: '@' }, template: // vaste hoogte: de tabs komen soms pas na de vertalingen, de pagina mag daardoor niet verspringen. // Eén regel, op mobiel horizontaal scrollen; de grijze lijn is een schaduw zodat de blauwe streep erover valt '' }; }); // --- toestand in plaats van inhoud: laden, leeg, bezig (met voortgang), fout, niet gekoppeld // kind: loading | empty | working | error | connect; progress 0-100 (bij working); action + on-action voor de knop. // layout="row": laag en over de volle breedte (icoon, tekst, knop naast elkaar), voor een lege toestand boven de // rest van een pagina die wel inhoud heeft; zonder layout een kaart in het midden, voor een pagina die verder leeg is. app.directive('mtState', function () { var icon = '
' + '
'; return { restrict: 'E', transclude: true, scope: { kind: '@', title: '@', text: '@', step: '@', progress: '' + '
' + icon + '

{{title}}

{{text}}

' + '{{action}}' + '
' + '
' + icon + '

{{title}}

' + '

{{text}}

' + '
' + '
' + '
{{step}}
' + '
' + '{{action}}' + '' + '
', link: function (scope, el, attrs) { // een link naar /rest/ (bv. Search Console koppelen) moet de pagina echt verlaten; anders vangt de router van // Angular (html5Mode) de klik af en stuurt hij een onbekende route door naar / attrs.$observe('href', function (h) { scope.rest = /^\/rest\//.test(h || ''); }); // title is hier de kop, geen browser-tooltip (die bleef naast de kaart hangen) attrs.$observe('title', function () { el[0].removeAttribute('title'); }); } }; }); // --- staafgrafiek per periode, gestapeld; met een lijn ter vergelijking (bv. vorig jaar) // data: {labels:[kort], titles:[tooltipkop], series:[{label, color, values}], line?:{label, color, values}, // faded?:[bool] (lichter: bv. ouder dan 16 maanden), extra?:[[{label, value}]] (extra tooltipregels per staaf)} // fmt: hoe een waarde in de tooltip en op de as staat (standaard een getal); max: vaste schaal (bv. 100 bij aandelen) app.directive('mtBars', ['mtChart', 'mtTip', 'mtFmt', function (mtChart, mtTip, mtFmt) { return { restrict: 'E', scope: { data: '<', height: '@', fmt: '
{{axis[0]}}{{axis[1]}}0
' + '
' + '
' + '
' + '
' + '' + '
' + '
{{l}}
' + '
' + '{{s.label}}' + '{{data.line.label}}
', link: function (scope) { scope.h = +(scope.height || 200); var f = function (v) { return scope.fmt ? scope.fmt(v) : mtFmt.short(v); }; scope.$watch('data', function (d) { scope.cols = []; scope.linePath = null; scope.axis = ['', '']; if (!d || !d.labels) { return; } var n = d.labels.length, max = 0; for (var i = 0; i < n; i++) { var sum = 0; d.series.forEach(function (s) { sum += +(s.values[i] || 0); }); max = Math.max(max, sum, d.line ? +(d.line.values[i] || 0) : 0); } max = scope.max > 0 ? scope.max : mtChart.niceMax(max); scope.axis = [f(max), f(max / 2)]; for (var j = 0; j < n; j++) { // de onderste reeks onderaan: in een flex-kolom komt het laatste element onderaan var parts = d.series.slice().reverse().map(function (s) { return { h: 100 * (+(s.values[j] || 0)) / max, color: s.color }; }); scope.cols.push({ parts: parts, faded: d.faded && d.faded[j] }); } if (d.line) { // de lijn door het midden van elke staaf var pts = []; d.line.values.forEach(function (v, k) { if (v === null || v === undefined) { return; } pts.push((k ? 'L' : 'M') + (1000 * (k + 0.5) / n).toFixed(1) + ' ' + (100 - 100 * v / max).toFixed(1)); }); scope.linePath = pts.join(' ').replace(/^L/, 'M'); } }); scope.tip = function (e, i) { var d = scope.data, rows = []; d.series.forEach(function (s) { rows.push({ color: s.color, label: s.label, value: f(s.values[i]) }); }); if (d.line && d.line.values[i] !== null && d.line.values[i] !== undefined) { rows.push({ color: d.line.color || '#9ca3af', label: d.line.label, value: f(d.line.values[i]) }); } ((d.extra && d.extra[i]) || []).forEach(function (r) { rows.push(r); }); mtTip.show(e, (d.titles || d.labels)[i], rows); }; scope.off = mtTip.hide; scope.$on('$destroy', mtTip.hide); } }; }]); // --- lijngrafiek per periode, met een punt per meting (bij één meting is dat alles wat er is) // data: {labels, titles?, series:[{label, color, values, width?, dash?, strong?}], guides?:[{label, color, value}]}; // invert: hoog = klein getal (posities). guides: vaste horizontale stippellijnen (bv. de grens goed/slecht), in de // legenda maar niet in de tooltip; de as reikt tot de hoogste grens // min/max: vaste as (anders 0 tot een rond maximum); ints: hele getallen (as minstens tot 2) app.directive('mtLine', ['mtChart', 'mtTip', 'mtFmt', function (mtChart, mtTip, mtFmt) { var W = 640; return { restrict: 'E', scope: { data: '<', height: '@', invert: '
{{axis[0]}}{{axis[1]}}{{axis[2]}}
' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
{{l}}
' + '
' + '{{s.label}}' + '{{g.label}}
', link: function (scope) { scope.h = +(scope.height || 200); scope.hover = null; var f = function (v) { return scope.fmt ? scope.fmt(v) : mtFmt.short(v); }; scope.$watch('data', function (d) { scope.paths = []; scope.bands = []; scope.guides = []; scope.axis = ['', '', '']; if (!d || !d.labels) { return; } var all = []; d.series.forEach(function (s) { s.values.forEach(function (v) { if (v !== null && v !== undefined && !isNaN(v)) { all.push(+v); } }); }); (d.guides || []).forEach(function (g) { all.push(+g.value); }); var min = scope.min !== undefined ? scope.min : (scope.invert ? 1 : 0); var top = Math.max.apply(null, all.concat([1])); var max = scope.max !== undefined ? scope.max : (scope.ints ? mtChart.niceInt(top) : mtChart.niceMax(top)); scope.axis = scope.invert ? [f(min), f((min + max) / 2), f(max)] : [f(max), f((min + max) / 2), f(min)]; scope.paths = mtChart.lines(d.series, { w: W, h: 200, n: d.labels.length, min: min, max: max, invert: !!scope.invert, padX: 6 }); scope.bands = mtChart.bands(d.labels.length, W, 6); // zelfde y als mtChart.lines (pad 6 op 200 hoog) scope.guides = (d.guides || []).map(function (g) { var t = (g.value - min) / ((max - min) || 1); return { y: (6 + (scope.invert ? t : 1 - t) * 188).toFixed(1), color: g.color }; }); }); scope.tip = function (e, i) { scope.hover = i; var d = scope.data; mtTip.show(e, (d.titles || d.labels)[i], d.series.map(function (s) { return { color: s.color, label: s.label, value: f(s.values[i]), strong: s.strong }; })); }; scope.off = function () { scope.hover = null; mtTip.hide(); }; scope.$on('$destroy', mtTip.hide); } }; }]); // --- minilijn in een tabelregel (Arjen 2026-10-28: één soort, geen grijze en blauwe staafjes): één kleur, het laatste // punt dikker, een stippellijn over maanden zonder waarde, per maand een tooltip. invert: hoog = klein getal (positie) // values: [getal|null]; labels: tooltipkop per waarde; label: naam van de waarde; empty: tekst bij geen waarde app.directive('mtSparkLine', ['mtChart', 'mtTip', 'mtFmt', function (mtChart, mtTip, mtFmt) { var W = 110, H = 24; return { restrict: 'E', scope: { values: '<', labels: '' + '' + '' + '' + '' + '', link: function (scope) { scope.$watch('values', function (v) { v = v || []; var nums = v.filter(function (x) { return x !== null && x !== undefined; }); scope.d = ''; scope.bridge = ''; scope.last = null; scope.singles = []; scope.hits = []; var n = v.length; if (!n) { return; } scope.hits = v.map(function (x, i) { return { x: i * W / n, w: W / n }; }); if (!nums.length) { return; } var lo = Math.min.apply(null, nums), hi = Math.max.apply(null, nums); if (scope.invert) { // een kleine schommeling geen grote golf: minstens 4 plaatsen hoog if (hi - lo < 4) { var c = (hi + lo) / 2; lo = Math.max(1, c - 2); hi = lo + 4; } } else { lo = 0; hi = Math.max(hi, 2); } var paths = mtChart.lines([{ values: v, nodots: true }], { w: W, h: H, n: n, min: lo, max: hi, invert: !!scope.invert, pad: 3 })[0]; scope.d = paths.d; scope.bridge = paths.bridge; scope.singles = paths.dots; for (var i = n - 1; i >= 0; i--) { if (v[i] !== null && v[i] !== undefined) { var px = n > 1 ? 3 + i * (W - 6) / (n - 1) : W / 2, t = (v[i] - lo) / ((hi - lo) || 1); scope.last = { x: px, y: 3 + (scope.invert ? t : 1 - t) * (H - 6) }; break; } } }); scope.tip = function (e, i) { var v = scope.values[i]; mtTip.show(e, scope.title + (scope.labels ? ' · ' + scope.labels[i] : ''), [{ label: scope.label || '', value: v === null || v === undefined ? (scope.empty || '–') : (scope.fmt ? scope.fmt(v) : mtFmt.num(v)) }]); }; scope.off = mtTip.hide; scope.$on('$destroy', mtTip.hide); } }; }]); // --- tooltip bij een cel of label: mt-hover-tip="{t: 'kop', r: [{label, value}]}" (zelfde tooltip als de grafieken) app.directive('mtHoverTip', ['mtTip', '$parse', function (mtTip, $parse) { return { restrict: 'A', link: function (scope, el, attrs) { var get = $parse(attrs.mtHoverTip); el.on('mousemove', function (e) { var d = get(scope); if (d) { mtTip.show(e, d.t, d.r || []); } }); el.on('mouseleave', mtTip.hide); scope.$on('$destroy', mtTip.hide); } }; }]); // --- minigrafiek in een tabelregel: staafjes, met een tooltip per staafje // values: [getal|null]; labels: tooltipkop per staafje; faded: aantal staafjes vooraan dat lichter is (bv. vorig jaar) app.directive('mtSpark', ['mtTip', 'mtFmt', function (mtTip, mtFmt) { return { restrict: 'E', scope: { values: '<', labels: '' + '
' + '
', link: function (scope) { scope.$watch('values', function (v) { v = v || []; var max = Math.max.apply(null, v.map(function (x) { return +x || 0; }).concat([1])); scope.bars = v.map(function (x, i) { var none = x === null || x === undefined; return { h: none ? 8 : Math.max(6, 100 * x / max), none: none, faded: i < (scope.faded || 0) }; }); }); scope.tip = function (e, i) { var v = scope.values[i]; mtTip.show(e, scope.title, [{ label: scope.labels ? scope.labels[i] : '', value: v === null || v === undefined ? '–' : (scope.fmt ? scope.fmt(v) : mtFmt.num(v)) }]); }; scope.off = mtTip.hide; scope.$on('$destroy', mtTip.hide); } }; }]); // --- aandeel als horizontale balk (0-100) app.directive('mtShare', function () { return { restrict: 'E', scope: { value: '<', color: '@' }, template: '
' }; }); // --- gestapelde horizontale balk: delen van een geheel (goed/matig/slecht) of opgetelde stappen (de LCP-onderdelen) // parts: [{label, value, color, text?, ink?}]; max: de schaal (standaard de som, dus altijd vol; geef het maximum van // alle regels mee om regels onderling te vergelijken); size: xs (dun, onder een getal) | sm | md (standaard, met witte // naden) | lg (22 px hoog, losse afgeronde blokjes met 2 px ertussen en text in het blokje als het breed genoeg is, in // de kleur ink, standaard wit; voor een balk per domein zoals de LCP-tijdverdeling); title + fmt: kop en notatie in de // tooltip (een regel per deel); empty: tekst in een gestippeld vak als er geen delen zijn (alleen lg) app.directive('mtStack', ['mtTip', 'mtFmt', function (mtTip, mtFmt) { var H = { xs: 'h-1', sm: 'h-1.5', md: 'h-3.5' }; return { restrict: 'E', scope: { parts: '<', max: '' + '
' + '
' + '
' + '
' + '
{{p.text}}
' + '
{{empty}}
', link: function (scope, el) { scope.h = H[scope.size] || H.md; scope.seams = !scope.size || scope.size === 'md'; function build() { var parts = (scope.parts || []).filter(function (p) { return p && p.value > 0; }); var sum = parts.reduce(function (a, p) { return a + (+p.value); }, 0); var max = scope.max > 0 ? Math.max(scope.max, sum) : sum; // lg: tekst alleen in een blokje van minstens 46 px breed (anders past hij niet; op een telefoon minder tekst) var px = el[0].offsetWidth || 600; scope.total = max ? 100 * sum / max : 0; scope.segs = parts.map(function (p) { var w = sum ? 100 * p.value / sum : 0; return { w: w, color: p.color, ink: p.ink, text: p.text && px * w * scope.total / 1e4 >= 46 ? p.text : '' }; }); } scope.$watchGroup(['parts', 'max'], build); if (scope.size === 'lg') { var onResize = function () { scope.$applyAsync(build); }; window.addEventListener('resize', onResize); scope.$on('$destroy', function () { window.removeEventListener('resize', onResize); }); // de eerste keer is de breedte er pas na het tekenen scope.$applyAsync(build); } scope.tip = function (e) { if (!scope.parts || !scope.parts.length) { return; } mtTip.show(e, scope.title, scope.parts.map(function (p) { return { color: p.color, label: p.label, value: p.value === null || p.value === undefined ? '–' : (scope.fmt ? scope.fmt(p.value) : mtFmt.num(p.value)) }; })); }; scope.off = mtTip.hide; scope.$on('$destroy', mtTip.hide); } }; }]); // --- donut: een verdeling in delen (de rechterkaart van Analytics). parts: [{label, value, color}] (al gesorteerd, de rest // als laatste deel in grijs); center: tekst in het midden, sub eronder; fmt: notatie in de tooltip. Een ring met 2 px wit // tussen de delen; de legenda staat ernaast in de pagina zelf (kleur nooit alleen). app.directive('mtDonut', ['mtTip', 'mtFmt', function (mtTip, mtFmt) { var R = 70, W = 20, C = 80; var pt = function (a, r) { return [C + r * Math.sin(a), C - r * Math.cos(a)]; }; var arc = function (a0, a1) { var big = a1 - a0 > Math.PI ? 1 : 0, o0 = pt(a0, R), o1 = pt(a1, R), i1 = pt(a1, R - W), i0 = pt(a0, R - W); return 'M' + o0 + 'A' + R + ',' + R + ' 0 ' + big + ' 1 ' + o1 + 'L' + i1 + 'A' + (R - W) + ',' + (R - W) + ' 0 ' + big + ' 0 ' + i0 + 'Z'; }; return { restrict: 'E', scope: { parts: '<', center: '@', sub: '@', fmt: '' + '' + '' + '' + '
' + '
{{center}}
{{sub}}
', link: function (scope) { scope.$watch('parts', function (parts) { parts = (parts || []).filter(function (p) { return p && p.value > 0; }); var sum = parts.reduce(function (a, p) { return a + (+p.value); }, 0), a = 0; scope.segs = parts.map(function (p) { var a1 = a + 2 * Math.PI * p.value / sum; // één deel van 100%: twee halve bogen (een boog van 360 graden tekent SVG niet) var d = parts.length === 1 ? arc(0, Math.PI) + arc(Math.PI, 2 * Math.PI - 1e-6) : arc(a, a1); var s = { d: d, color: p.color, label: p.label, value: p.value, pct: 100 * p.value / sum }; a = a1; return s; }); }); scope.tip = function (e, s) { mtTip.show(e, scope.title, [{ color: s.color, label: s.label, value: (scope.fmt ? scope.fmt(s.value) : mtFmt.num(s.value)) + ' · ' + mtFmt.pct(s.pct, 0) }]); }; scope.off = mtTip.hide; scope.$on('$destroy', mtTip.hide); } }; }]); // --- bolletje met een oordeel, bv. een positie in Google die wel of niet slaagt; tooltip met mt-hover-tip erbij // tone: good | ni | poor | none (geen data) | pending (nog niet bekend); ring: blauw omcirkeld (jij); text: in het bolletje; // size="sm": klein, zonder tekst (legenda) app.directive('mtDot', function () { var C = { good: 'bg-green-500 text-white', ni: 'bg-amber-400 text-white', poor: 'bg-red-500 text-white', none: 'bg-gray-200 text-gray-500', pending: 'bg-white border border-dashed border-gray-300 text-gray-400' }; return { restrict: 'E', scope: { tone: '@', ring: '{{text}}', link: function (scope) { scope.c = C; } }; }); // --- favicon van een domein (zelfde bron als de sitelijst); verdwijnt als er geen is, zodat er nooit een kapot plaatje staat app.directive('mtFavicon', function () { return { restrict: 'E', scope: { domain: '@', size: '@' }, template: '', link: function (scope) { scope.$watch('domain', function (d) { scope.host = String(d || '').toLowerCase().replace(/^https?:\/\//, '').replace(/[\/?#:].*$/, ''); }); } }; }); // --- Core Web Vitals: de grenzen van Google, het oordeel, de notatie en de kleuren; voor elke pagina met CrUX- of // RUM-cijfers (zelfde grenzen als crux::LIMITS in rest/include/crux/rules.php) app.factory('mtCwv', ['mtFmt', '$translate', function (mtFmt, $translate) { var LIMITS = { lcp: [2500, 4000], inp: [200, 500], cls: [0.1, 0.25], fcp: [1800, 3000], ttfb: [800, 1800] }; var COLORS = { good: '#22c55e', ni: '#f59e0b', poor: '#ef4444' }; var cwv = { LIMITS: LIMITS, COLORS: COLORS, // good | ni | poor | null rating: function (m, v) { if (v === null || v === undefined || isNaN(v) || !LIMITS[m]) { return null; } return v <= LIMITS[m][0] ? 'good' : (v <= LIMITS[m][1] ? 'ni' : 'poor'); }, // LCP en FCP in seconden (0,59 s), CLS op twee decimalen, de rest in ms (176 ms) fmt: function (m, v) { if (v === null || v === undefined || isNaN(v)) { return '–'; } if (m === 'cls') { return mtFmt.num(v, 2); } if (m === 'lcp' || m === 'fcp') { return mtFmt.num(v / 1000, 2) + ' s'; } return mtFmt.num(v) + ' ms'; }, // een tijd onder een seconde in ms (358 ms), daarboven in seconden (1,15 s): de LCP-stappen, TTFB, FCP, rekentijd ms: function (v) { if (v === null || v === undefined || isNaN(v)) { return '–'; } return v < 1000 ? mtFmt.num(v) + ' ms' : mtFmt.num(v / 1000, 2) + ' s'; }, // de schaal van een : goed tot, matig tot, einde van de balk SCALE: { lcp: [2500, 4000, 5000], inp: [200, 500, 700], cls: [0.1, 0.25, 0.4] }, // lichte vlakken voor de zones in een grafiek of balk ZONES: { good: '#f0fdf4', ni: '#fffbeb', poor: '#fef2f2' }, TRACK: { good: '#bbf7d0', ni: '#fde68a', poor: '#fecaca' }, // een notatiefunctie voor één metric (voor fmt="..." van mt-line, mt-spark-line, mt-stack) fmtOf: function (m) { return function (v) { return cwv.fmt(m, v); }; }, // verdeling {good, ni, poor} (aandelen 0-1) naar delen voor distParts: function (d) { if (!d) { return null; } return ['good', 'ni', 'poor'].map(function (k) { return { label: $translate.instant('ui.cwv.' + k), value: d[k] || 0, color: COLORS[k] }; }); }, pct: function (v) { return mtFmt.pct(100 * v, 0); } }; return cwv; }]); // --- label voor een soort bevinding; elke soort heeft overal dezelfde kleur // kind: chance | decay | cannibal | lowctr | new | lost | drop app.directive('mtBadge', function () { var C = { chance: 'bg-green-100 text-green-800', decay: 'bg-red-100 text-red-800', cannibal: 'bg-amber-100 text-amber-800', lowctr: 'bg-violet-100 text-violet-800', 'new': 'bg-blue-100 text-blue-800', lost: 'bg-gray-100 text-gray-700', drop: 'bg-red-100 text-red-800', // SEO-audit: een pagina die weer fout is, een pagina die onze bot niet binnenliet regressed: 'bg-red-100 text-red-800', blocked: 'bg-gray-100 text-gray-600', // AI Insights not_mentioned: 'bg-red-100 text-red-800', no_conversion: 'bg-violet-100 text-violet-800', idea: 'bg-blue-100 text-blue-800', // Core Web Vitals: geslaagd, niet geslaagd, te weinig Chrome-verkeer cwv_pass: 'bg-green-100 text-green-700', cwv_fail: 'bg-red-100 text-red-700', cwv_nodata: 'bg-gray-100 text-gray-500' }; var ICON = { cwv_pass: 'fa-check', cwv_fail: 'fa-times', cwv_nodata: 'fa-minus' }; return { restrict: 'E', scope: { kind: '@' }, // Core Web Vitals als pil met een icoon (vinkje, kruis, streepje): een status nooit alleen met kleur template: '{{\'ui.badge.\' + kind | translate}}' + '{{\'ui.badge.\' + kind | translate}}', link: function (scope) { scope.c = C; scope.icon = ICON; } }; }); // --- AI-antwoord: status van je site in één antwoord (AI Insights). status: linked (bron op plek pos) | mentioned | // none | nosearch (de AI zocht niet op het web) | leeg (niet gemeten) app.directive('mtAiStatus', function () { var C = { linked: 'bg-green-100 text-green-800', mentioned: 'bg-blue-100 text-blue-800', none: 'bg-gray-100 text-gray-500', nosearch: 'bg-amber-50 text-amber-800' }; return { restrict: 'E', scope: { status: '@', pos: '@' }, template: '' + '{{\'ui.ai_status.\' + status | translate:{pos: pos} }}–', link: function (scope) { scope.c = C; } }; }); // --- ster: volgen of niet (zoekwoorden) app.directive('mtStar', function () { return { restrict: 'E', scope: { on: '<', onToggle: '&' }, template: '' }; }); // --- kolomkop die sorteert: // sort: {key, desc}; een klik op dezelfde kolom draait de richting om app.directive('mtTh', function () { return { restrict: 'A', scope: { key: '@mtTh', sort: '=', label: '@', align: '@' }, template: '{{label}}', link: function (scope, el) { el.addClass('py-2 pr-4 font-medium' + (scope.align === 'right' ? ' text-right' : '')); scope.go = function () { if (scope.sort.key === scope.key) { scope.sort.desc = !scope.sort.desc; } else { scope.sort.key = scope.key; scope.sort.desc = true; } }; } }; }); // --- Core Web Vital in een tabelregel (scorebord): het getal, een korte noot ernaast, en daaronder een dunne balk met // de zones goed/matig/slecht van Google (mtCwv.SCALE) met een stip op de waarde; own: de stip blauw (jij), anders grijs // metric: lcp | inp | cls; value: getal of null (dan '–' en geen stip); text: eigen notatie (standaard mtCwv.fmt) app.directive('mtCwvTrack', ['mtCwv', function (mtCwv) { return { restrict: 'E', scope: { metric: '@', value: '<', text: '@', note: '@', own: '' + '
{{text || shown}}' + '{{note}}
' + '
' + '
' + '
', link: function (scope) { scope.c = mtCwv.TRACK; scope.$watchGroup(['metric', 'value'], function () { var s = mtCwv.SCALE[scope.metric] || [1, 2, 3], v = scope.value; scope.z = [s[0] / s[2], (s[1] - s[0]) / s[2], (s[2] - s[1]) / s[2]]; scope.x = v === null || v === undefined || isNaN(v) ? null : 100 * Math.min(Math.max(v, 0), s[2]) / s[2]; scope.shown = mtCwv.fmt(scope.metric, v); }); } }; }]); // --- kleine grafieken naast elkaar (small multiples, Arjen 2026-10-28: de trend was "rommelig"): per paneel één metric // over dezelfde weken, de zones van Google als lichte vlakken, jij als dikke blauwe lijn met een stip aan het eind. // mode "group": de concurrenten als één grijze band (snelste tot traagste per week) met de middelste als stippellijn; // mode "each": een dunne lijn per concurrent in een vaste kleur per domein (nooit per rang), met legenda erboven en het // domein aan het eind van elke lijn. Eén muis voor alle panelen: een verticale lijn en een tooltip per paneel in // dezelfde week. Een week zonder waarde is een gat, nooit 0. // data: {n, titles: [kop per week], months: [{i, label}], labels: {own, med, nodata}, others: [{label, color}] (legenda), // panels: [{title, sub, fmt, tick, max, ticks: [..], good, poor, limitLabel, own: [..], band: {lo, hi}, med: [..], // others: [{label, color, values}], takeaway}]} app.directive('mtMultiples', function () { var W = 400, H = 190; function X(i, n) { return n > 1 ? i * W / (n - 1) : W / 2; } // een pad met losse stukken waar data ontbreekt; een stuk van één week een rond puntje function path(vals, n, Y) { var d = '', pen = false; (vals || []).forEach(function (v, i) { if (v === null || v === undefined || isNaN(v)) { pen = false; return; } var next = vals[i + 1]; var alone = !pen && (next === null || next === undefined || isNaN(next)); d += (pen ? ' L' : ' M') + X(i, n).toFixed(1) + ' ' + Y(v).toFixed(1) + (alone ? ' h0.01' : ''); pen = true; }); return d.trim(); } function bandPath(lo, hi, n, Y) { var out = [], run = []; function flush() { if (run.length > 1) { out.push('M' + run.map(function (i) { return X(i, n).toFixed(1) + ' ' + Y(hi[i]).toFixed(1); }).join(' L') + ' L' + run.slice().reverse().map(function (i) { return X(i, n).toFixed(1) + ' ' + Y(lo[i]).toFixed(1); }).join(' L') + ' Z'); } run = []; } for (var i = 0; i < n; i++) { if (lo[i] !== null && lo[i] !== undefined && hi[i] !== null && hi[i] !== undefined) { run.push(i); } else { flush(); } } flush(); return out.join(' '); } function lastOf(vals) { for (var i = (vals || []).length - 1; i >= 0; i--) { if (vals[i] !== null && vals[i] !== undefined) { return i; } } return -1; } return { restrict: 'E', scope: { data: '<', mode: '@' }, template: '
' + '{{data.labels.own}}' + '{{o.label}}
' + '
' + '
' + '
{{p.title}}
{{p.sub}}
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
{{t.label}}
' + '
{{p.limitLabel}}
' + '
' + '
' + '
{{l.label}}
' + '
' + '
{{data.titles[hover]}}
' + '
{{r.label}}{{r.value}}
' + '
{{m.label}}
' + '
{{p.takeaway}}
', link: function (scope) { scope.hover = null; function build() { var d = scope.data, each = scope.mode === 'each'; scope.panels = []; scope.gutter = each ? 84 : 0; if (!d || !d.panels) { return; } var n = d.n; (d.months || []).forEach(function (m) { m.x = n > 1 ? 100 * m.i / (n - 1) : 50; }); scope.panels = d.panels.map(function (p) { var max = p.max || 1; var Y = function (v) { return H - Math.min(Math.max(v, 0), max) / max * H; }; var gy = Y(p.good), ny = p.poor < max ? Y(p.poor) : 0; var others = (p.others || []).map(function (o) { var li = lastOf(o.values); return { label: o.label, color: o.color, d: path(o.values, n, Y), end: li >= 0 ? { x: 100 * X(li, n) / W, y: 100 * Y(o.values[li]) / H } : null }; }); var li = lastOf(p.own); // het domein aan het eind van elke lijn, niet over elkaar (minstens 7,5% van de hoogte uit elkaar) var labels = others.filter(function (o) { return o.end; }).map(function (o) { return { label: o.label, ink: '#374151', y: o.end.y }; }) .sort(function (a, b) { return a.y - b.y; }); for (var k = 1; k < labels.length; k++) { labels[k].y = Math.max(labels[k].y, labels[k - 1].y + 7.5); } var over = labels.length ? labels[labels.length - 1].y - 97 : 0; labels.forEach(function (l) { l.y = Math.max(3, l.y - Math.max(over, 0)); }); return { title: p.title, sub: p.sub, takeaway: p.takeaway, fmt: p.fmt, src: p, zGoodY: gy, zGoodH: H - gy, zNiY: ny, zNiH: Math.max(gy - ny, 0), zPoorH: p.poor < max ? ny : 0, limitTop: p.good < max ? 100 * gy / H : null, limitLabel: p.limitLabel, ticks: (p.ticks || []).map(function (t) { return { y: 100 * Y(t) / H, label: p.tick(t) }; }), band: p.band ? bandPath(p.band.lo, p.band.hi, n, Y) : '', med: path(p.med, n, Y), own: path(p.own, n, Y), others: others, labels: labels, end: li >= 0 ? { x: 100 * X(li, n) / W, y: 100 * Y(p.own[li]) / H } : null, tip: [] }; }); if (scope.hover !== null) { tips(scope.hover); } } function tips(i) { var d = scope.data, each = scope.mode === 'each', L = d.labels || {}; scope.panels.forEach(function (p) { var s = p.src, f = s.fmt, rows = []; var own = s.own[i]; rows.push({ color: '#60a5fa', label: L.own, value: own === null || own === undefined ? '– (' + L.nodata + ')' : f(own) }); if (each) { (s.others || []).forEach(function (o) { rows.push({ color: o.color, label: o.label, value: o.values[i] === null || o.values[i] === undefined ? '–' : f(o.values[i]) }); }); } else if (s.med && s.med.length) { rows.push({ dash: true, label: L.med, value: s.med[i] === null || s.med[i] === undefined ? '–' : f(s.med[i]) }); } p.tip = rows; }); } scope.$watchGroup(['data', 'mode'], build); scope.move = function (e) { var n = scope.data.n, r = e.currentTarget.getBoundingClientRect(); var i = n > 1 ? Math.round(Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1) * (n - 1)) : 0; if (i === scope.hover) { return; } scope.hover = i; scope.hx = X(i, n); scope.hpct = 100 * scope.hx / W; scope.shift = scope.hpct < 22 ? 'translateX(-8px)' : (scope.hpct > 78 ? 'translateX(calc(-100% + 8px))' : 'translateX(-50%)'); tips(i); }; scope.leave = function () { scope.hover = null; }; } }; }); // --- twee grafieken onder elkaar over dezelfde weken, met één muis (nooit twee assen in één grafiek): boven een lijn // (bv. de LCP, met de zones van Google als lichte vlakken), onder staven (bv. bezoekers). Een verticale lijn door beide // en één tooltip boven. Een week zonder waarde: een gat in de lijn, geen staaf. // data: {n, titles: [kop per week], months: [{i, label}], top: {title, sub, label, values, fmt, tick, max, good, poor}, // bottom: {title, sub, badge, label, values, fmt, tick, max}} app.directive('mtPair', function () { var W = 1000, H = 170; return { restrict: 'E', transclude: true, scope: { data: '<' }, template: // één grafiek (data.overlay, Arjen 2026-10-29: "1 grafiek over elkaar", "links snelheid rechts pagina's per sessie"): // de lijn met de zones en de as links in blauw, de staven erachter met hun eigen as rechts in grijs '
' + '
{{data.overlay.title}}
{{data.top.sub}} · {{data.bottom.sub}}
' + '
{{data.top.label}} ({{data.overlay.left}})' + '{{data.bottom.label}} ({{data.overlay.right}})
' + '
' + '' + '
' + '' + '' + '' + '' + '
{{t.axis[0]}}
' + '
{{t.axis[1]}}
' + '
{{t.axis[2]}}
' + '
{{b.axis[0]}}
' + '
{{b.axis[2]}}
' + '
{{b.axis[1]}}
' + '
' + '
{{data.titles[hover]}}
{{tipText}}
' + '
{{m.label}}
' + '
' + '
' + '
' + '
{{data.top.title}}
{{data.top.sub}}
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '
{{t.axis[0]}}
' + '
{{t.axis[1]}}
' + '
{{t.axis[2]}}
' + '
' + '
{{data.titles[hover]}}
{{tipText}}
' + '
{{m.label}}
' + '
' + '
{{data.bottom.title}}{{data.bottom.badge}}
{{data.bottom.sub}}
' + '
' + '
' + '
' + '
' + '
{{b.axis[0]}}
' + '
{{b.axis[1]}}
' + '
' + '
{{m.label}}
', link: function (scope) { scope.hover = null; function x(i, n) { return (i + 0.5) / n; } scope.$watch('data', function (d) { scope.t = {}; scope.b = {}; scope.bars = []; scope.months = []; // andere gegevens (een andere metric): de oude tooltip klopt niet meer scope.hover = null; if (!d || !d.top) { return; } var n = d.n, tp = d.top, max = tp.max || 1; var Y = function (v) { return H - Math.min(Math.max(v, 0), max) / max * H; }; var dd = '', pen = false; (tp.values || []).forEach(function (v, i) { if (v === null || v === undefined) { pen = false; return; } var next = tp.values[i + 1], alone = !pen && (next === null || next === undefined); dd += (pen ? ' L' : ' M') + (W * x(i, n)).toFixed(1) + ' ' + Y(v).toFixed(1) + (alone ? ' h0.01' : ''); pen = true; }); var gy = Y(tp.good), ny = tp.poor < max ? Y(tp.poor) : 0; scope.t = { d: dd.trim(), zGoodY: gy, zGoodH: H - gy, zNiY: ny, zNiH: Math.max(gy - ny, 0), zPoorH: tp.poor < max ? ny : 0, axis: [tp.tick(max), tp.tick(max / 2), tp.tick(0)] }; scope.months = (d.months || []).map(function (m) { return { label: m.label, x: 100 * x(m.i, n) }; }); var bt = d.bottom; if (bt && bt.values) { var bm = bt.max || 1; scope.b = { axis: [bt.tick(bm), bt.tick(0), bt.tick(bm / 2)] }; scope.bars = bt.values.map(function (v) { return { h: v === null || v === undefined ? null : Math.max(1, 100 * Math.min(v, bm) / bm) }; }); } }); scope.move = function (e) { var d = scope.data, n = d.n, r = e.currentTarget.getBoundingClientRect(); var i = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - r.left) / r.width * n))); scope.hover = i; scope.hpct = 100 * x(i, n); scope.hx = W * x(i, n); scope.shift = scope.hpct < 15 ? 'translateX(-8px)' : (scope.hpct > 85 ? 'translateX(calc(-100% + 8px))' : 'translateX(-50%)'); var tv = d.top.values[i], bv = d.bottom && d.bottom.values ? d.bottom.values[i] : undefined; scope.tipText = d.top.label + ' ' + (tv === null || tv === undefined ? '–' : d.top.fmt(tv)) + (bv !== undefined ? ' · ' + d.bottom.label + ' ' + (bv === null ? '–' : d.bottom.fmt(bv)) : ''); }; scope.leave = function () { scope.hover = null; }; } }; }); // --- bevinding in een kaartje (Diagnose): soort met een gekleurde stip, een vette zin en een regel uitleg // kind: het label (bv. "Advertenties", "Concurrent"); color: de stip; head: de bevinding; text: wat het betekent app.directive('mtFinding', function () { return { restrict: 'E', scope: { kind: '@', color: '@', head: '@', text: '@' }, template: '
' + '
' + '{{kind}}
' + '
{{head}}
' + '
{{text}}
' }; }); // --- screenshot met markeringen (scanpagina): de pagina zoals de scan hem zag, met een kader per element (link, kop, // afbeelding) en één element uitgelicht (de rest donker) waar de lijst ernaast naar wijst; de screenshot scrolt erheen. // src: de afbeelding; natural: de breedte waarop gescand is (1366 desktop, 360 telefoon); boxes: [{b: {l,t,w,h}, color}]; // focus: de bounds van het uitgelichte element (of null); height: hoogte van het venster in px app.directive('mtShot', ['$timeout', function ($timeout) { return { restrict: 'E', scope: { src: '@', natural: '<', boxes: '<', focus: '<', height: '@', alt: '@' }, template: '
' + '
' + '{{alt}}' + '' + '' + '
', link: function (scope, el) { var box = el[0].firstChild; var pos = function (b) { var s = scope.scale || 0; return { left: (100 * b.l / scope.natural) + '%', width: Math.max(0.4, 100 * b.w / scope.natural) + '%', top: (b.t * s) + 'px', height: Math.max(4, b.h * s) + 'px' }; }; var visible = function (b) { return b && b.w > 0 && b.h > 0 && b.l >= 0 && b.t >= 0; }; var size = function () { var s = (box.clientWidth || 1) / (scope.natural || 1366); if (s !== scope.scale) { scope.scale = s; draw(); light(scope.focus); } }; var draw = function () { scope.shown = (scope.boxes || []).filter(function (x) { return visible(x.b); }).map(function (x) { return { style: angular.extend(pos(x.b), { border: '1px solid ' + (x.color || '#2563eb') + 'aa', background: (x.color || '#2563eb') + '14' }) }; }); }; // het uitgelichte element: dik kader, de rest van de pagina donker var light = function (b) { scope.hit = visible(b) ? angular.extend(pos(b), { border: '2px solid #2563eb', background: 'rgba(37,99,235,.12)', 'box-shadow': '0 0 0 9999px rgba(17,24,39,.35)', 'z-index': 2 }) : null; }; scope.$watch('boxes', draw); scope.$watch('focus', function (b) { light(b); if (visible(b)) { $timeout(function () { size(); box.scrollTo({ top: Math.max(0, b.t * scope.scale - 120), behavior: 'smooth' }); }); } }); size(); el.find('img').on('load', function () { scope.$applyAsync(size); }); var onResize = function () { scope.$applyAsync(size); }; window.addEventListener('resize', onResize); scope.$on('$destroy', function () { window.removeEventListener('resize', onResize); }); } }; }]); // Wat bewaard blijft tussen de pagina's van Search Console Insights: de laatst opgehaalde antwoorden per adres // (direct tonen, op de achtergrond vernieuwen) en de filters van Verkennen. var gsciState = { site: null, cache: {}, explore: null, compare: 'prev' }; // Search Console in een rapport (newlayout/gscinsights/report.html): de periode van het rapport ($rootScope.reportdates) // tegenover de periode ervoor, en de grootste 25 zoekwoorden, pagina's, landen of apparaten (custominit.urlview) app.controller('gscinsightsReport', ['$scope', '$rootScope', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $http, $translate, h, mtFmt) { var DIMS = { keywords: 'query', pages: 'page', country: 'country', device: 'device' }; var view = ($scope.custominit && $scope.custominit.urlview) || 'keywords'; var dim = DIMS[view] || 'query'; var t = function (k, v) { return $translate.instant(k, v); }; var r = $scope.r = { f: mtFmt, rows: null, chart: null, title: '', kpis: ['clicks', 'impressions', 'ctr', 'position'].map(function (k) { return { label: '', loading: true }; }) }; var dates = $rootScope.reportdates || {}; var base = { websiteid: h.siteId(), start: dates.start || '30DAYSAGO', end: dates.end || 'TODAY' }; var countryName = function (cc) { try { return cc ? new Intl.DisplayNames([$translate.use() || 'nl'], { type: 'region' }).of(cc) : ''; } catch (e) { return cc; } }; $translate.onReady().then(function () { r.title = t('gscinsights.explore.dim.' + dim); $http.get('/rest/gscinsights/report', { params: angular.extend({ dimension: dim, compare: 'PERIOD', limit: 25 }, base) }).then(function (res) { var d = res.data, now = (d.totals || [])[0] || {}, before = (d.totals || [])[1]; var ch = function (a, b) { return b ? 100 * (a - b) / b : null; }; r.kpis = [ { label: t('gscinsights.kpi.clicks'), value: mtFmt.num(now.clicks), delta: before ? ch(now.clicks, before.clicks) : null }, { label: t('gscinsights.kpi.impressions'), value: mtFmt.num(now.impressions), delta: before ? ch(now.impressions, before.impressions) : null }, { label: t('gscinsights.kpi.ctr'), value: mtFmt.pct(now.ctr), delta: before ? now.ctr - before.ctr : null, unit: 'pt' }, { label: t('gscinsights.kpi.position'), value: mtFmt.pos(now.position), delta: before && now.position && before.position ? now.position - before.position : null, invert: true, unit: 'none' } ]; r.rows = (d.data || []).map(function (x) { x.label = dim === 'page' ? (x.page.replace(/^https?:\/\/[^\/]+/, '') || '/') : (dim === 'device' ? t('gscinsights.explore.device_name.' + x.device) : (dim === 'country' ? countryName(x.cc) || (x.country || '').toUpperCase() : x.query)); return x; }); }); $http.get('/rest/gscinsights/report', { params: angular.extend({ dimension: 'date' }, base) }).then(function (res) { var rows = res.data.data || []; r.chart = { labels: rows.map(function (x, i) { return i % 7 === 0 ? mtFmt.dayWeek(x.date) : ''; }), titles: rows.map(function (x) { return mtFmt.dayWeek(x.date); }), series: [{ label: t('gscinsights.kpi.clicks'), color: '#2563eb', values: rows.map(function (x) { return x.clicks; }) }] }; }); }); }]); // site-instellingen: Search Console alleen voor één land (standaard wereldwijd) app.controller('gscinsightsSiteSettings', ['$scope', '$http', 'h', function ($scope, $http, h) { var s = $scope.gsciSettings = { country: '', loaded: false, saved: false }; var load = function () { $http.get('/rest/gscinsights/settings', { params: { websiteid: h.siteId() } }).then(function (res) { s.country = res.data.country || ''; s.canEdit = res.data.can_edit; s.property = res.data.property; s.connected = res.data.connected; s.siteId = h.siteId(); s.loaded = true; }); }; load(); // ontkoppeld in Koppelingen (js/controller/settings.site.js): opnieuw ophalen en Search Insights niet uit de cache $scope.$on('siteConnectionsChanged', function () { gsciState.cache = {}; load(); }); s.save = function () { $http.post('/rest/gscinsights/settings', { websiteid: h.siteId(), country: s.country }).then(function () { s.saved = true; gsciState.cache = {}; }); }; }]); // Search Insights in tegels, zinnen en grafieken; gedeeld met het Dashboard (js/controller/home.js), zodat beide hetzelfde // laten zien: de cijfertegels, de grafiek per maand, waarom het veranderde en de kansen. app.factory('gsciView', ['$translate', 'mtFmt', function ($translate, mtFmt) { var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; var BASE = '/search-insights'; var view = { kpis: function (now, before, ga4) { var ch = function (a, b) { return b ? 100 * (a - b) / b : null; }; var out = [ { key: 'clicks', label: t('gscinsights.kpi.clicks'), value: f.num(now.clicks), delta: before ? ch(now.clicks, before.clicks) : null }, { key: 'impressions', label: t('gscinsights.kpi.impressions'), value: f.num(now.impressions), delta: before ? ch(now.impressions, before.impressions) : null }, { key: 'ctr', label: t('gscinsights.kpi.ctr'), value: f.pct(now.ctr), delta: before ? now.ctr - before.ctr : null, unit: 'pt', digits: 1 }, { key: 'position', label: t('gscinsights.kpi.position'), value: f.pos(now.position), delta: before && now.position && before.position ? now.position - before.position : null, invert: true, unit: 'none', digits: 1, note: t('gscinsights.kpi.position_note') } ]; if (ga4 === null || ga4 === undefined) { out.push({ label: t('gscinsights.kpi.revenue'), value: '–', note: t('gscinsights.kpi.no_ga4') }); } else if (ga4.has_revenue) { out.push({ key: 'revenue', label: t('gscinsights.kpi.revenue'), value: f.euro(ga4.now.revenue), delta: ch(ga4.now.revenue, ga4.before && ga4.before.revenue), note: t('gscinsights.kpi.organic') }); } else { out.push({ key: 'conversions', label: t('gscinsights.kpi.conversions'), value: f.num(ga4.now.conversions), delta: ch(ga4.now.conversions, ga4.before && ga4.before.conversions), note: t('gscinsights.kpi.organic') }); } return out; }, causeText: function (r) { if (r.cause === 'position') { return t('gscinsights.cause.position', { from: f.pos(r.before.position), to: f.pos(r.now.position) }); } if (r.cause === 'demand') { return t('gscinsights.cause.demand', { pct: f.num(100 * (r.now.impressions - r.before.impressions) / r.before.impressions) + '%' }); } return t('gscinsights.cause.' + r.cause); }, actionText: function (a) { if (a.kind === 'chance') { return t('gscinsights.action.chance', { q: a.query, pos: f.pos(a.position), impr: f.num(a.impressions), gain: f.num(a.gain) }); } if (a.kind === 'lowctr') { return t('gscinsights.action.lowctr', { q: a.query, pos: f.pos(a.position), ctr: f.pct(a.ctr), exp: f.pct(a.expected_ctr) }); } if (a.kind === 'decay') { var cause = view.causeText({ cause: a.cause, before: { position: a.position_before, impressions: a.impressions_before }, now: { position: a.position, impressions: a.impressions } }); return t('gscinsights.action.decay', { q: a.query, pct: f.num(-a.change) + '%', cause: cause }); } if (a.kind === 'cannibal') { return t('gscinsights.action.cannibal', { q: a.query, n: a.pages.length, shares: a.pages.map(function (p) { return p.share + '%'; }).join(' / ') }); } return a.query; }, exploreHref: function (q) { return BASE + '/verkennen?q=' + encodeURIComponent(q) + '&q_op=equals'; }, // waarom het steeg of daalde (part why): een zin en de zoekwoorden met het grootste verschil why: function (w) { return { down: w.diff < 0, text: t(w.diff < 0 ? 'gscinsights.overview.why_down' : 'gscinsights.overview.why_up', { n: f.num(Math.abs(w.diff)) }), rows: w.rows.map(function (r) { return { q: r.query, diff: f.num(r.diff), up: r.diff > 0, why: view.causeText(r), href: view.exploreHref(r.query) }; }) }; }, overviewChart: function (d, metric) { var months = d.months || [], brand = d.brand.length > 0; var col = function (k) { return months.map(function (m) { return m[k] === undefined ? null : m[k]; }); }; var labels = months.map(function (m) { return f.month(m.month); }); var titles = months.map(function (m) { return f.monthLong(m.month) + (m.partial ? ' · ' + t('gscinsights.overview.partial') : ''); }); var lastYear = function (k) { return { label: t('gscinsights.overview.last_year'), color: '#9ca3af', values: months.map(function (m) { return m.ly ? m.ly[k] : null; }) }; }; var out = { chartKind: 'bars', chartFmt: null, chartInvert: false, brandNote: '', chartTitle: t('gscinsights.overview.' + metric + '_per_month') }; if (metric === 'ctr' || metric === 'position') { angular.extend(out, { chartKind: 'line', chartFmt: metric === 'ctr' ? function (v) { return f.pct(v); } : f.pos, chartInvert: metric === 'position', chart: { labels: labels, titles: titles, series: [ { label: t('gscinsights.kpi.' + metric), color: '#3b82f6', values: col(metric), width: 2.5, strong: true }, angular.extend(lastYear(metric), { dash: true }) ] } }); return out; } if (metric === 'revenue' || metric === 'conversions') { out.chartFmt = metric === 'revenue' ? f.euro : null; out.chart = { labels: labels, titles: titles, faded: col('partial'), line: lastYear(metric), series: [{ label: t('gscinsights.kpi.' + metric), color: '#047857', values: col(metric) }] }; return out; } if (metric !== 'impressions') { metric = 'clicks'; out.chartTitle = t('gscinsights.overview.clicks_per_month'); } var brandKey = metric === 'clicks' ? 'brand' : 'brand_impressions'; out.chart = { labels: labels, titles: titles, faded: col('partial'), line: lastYear(metric), series: brand ? [ { label: t('gscinsights.overview.brand'), color: '#1e40af', values: col(brandKey) }, { label: t('gscinsights.overview.nonbrand'), color: '#60a5fa', values: months.map(function (m) { return m[metric] === null ? null : m[metric] - m[brandKey]; }) } ] : [{ label: t('gscinsights.kpi.' + metric), color: '#3b82f6', values: col(metric) }], // bij klikken in de tooltip ook de opbrengst van die maand extra: metric === 'clicks' ? months.map(function (m) { return m.revenue !== null ? [{ label: t('gscinsights.overview.revenue'), value: f.euro(m.revenue), color: '#047857' }] : []; }) : null }; out.brandNote = brand ? t('gscinsights.overview.brand_terms', { terms: d.brand.join(', ') }) : ''; return out; }, }; return view; }]); app.controller('gscinsights', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', '$timeout', 'h', 'mtFmt', 'gsciView', function ($scope, $rootScope, $location, $routeParams, $http, $translate, $timeout, h, mtFmt, gsciView) { // Search Console Insights: /search-insights (overzicht), /search-insights/onderwerpen(/:id), /gevolgd, /verkennen. // Server: rest/data/gscinsights/, regels in rest/include/gscinsights.php. Bouwblokken: js/ui.js (docs/ui.md). // Nog niet gelanceerd: zichtbaar via user.site.show.gscinsights. // in een rapport (newlayout/gscinsights/report_page.html): de maand van het rapport tegenover de maand ervoor, // alleen lezen. custominit.urlview: overview | topics | tracked | actions var report = $scope.custominit && $scope.custominit.gsci ? $scope.custominit : null; if (!report && (!$rootScope.user || !$rootScope.user.site || !$rootScope.user.site.show || !$rootScope.user.site.show.gscinsights)) { $location.path('/'); return; } var TABS = { '': 'overview', onderwerpen: 'topics', gevolgd: 'tracked', verkennen: 'explore' }; var BASE = '/search-insights'; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (gsciState.site !== h.siteId()) { gsciState = { site: h.siteId(), cache: {}, explore: null, compare: 'prev' }; } var c = $scope.c = { f: f, tab: report ? (report.urlview === 'actions' ? 'overview' : report.urlview) : (TABS[$routeParams.tab || ''] || 'overview'), topicId: report ? null : ($routeParams.id || null), compare: report ? 'prev' : gsciState.compare, report: report ? report.urlview : null, status: gsciState.cache.status || null, data: null, loading: true, error: null, v: {}, // welke tegel op het overzicht de grafiek bepaalt metric: 'clicks', init: function () { var self = this; self.tabs = [ { key: 'overview', label: t('gscinsights.tab.overview'), href: BASE }, { key: 'topics', label: t('gscinsights.tab.topics'), href: BASE + '/onderwerpen' }, { key: 'tracked', label: t('gscinsights.tab.tracked'), href: BASE + '/gevolgd' }, { key: 'explore', label: t('gscinsights.tab.explore'), href: BASE + '/verkennen' } ]; self.siteId = h.siteId(); self.get('status', {}).then(function (s) { self.status = s; // de properties van het gekoppelde account alleen voor wie de koppeling mag beheren (bij een white label: alle klanten) if (s.gsc === 'no_property' && s.can_manage) { self.loadProperties(); } if (s.gsc !== 'ok') { self.loading = false; return; } self.load(); }); }, // gekoppeld zonder property: de lijst om uit te kiezen, en kiezen loadProperties: function () { var self = this; $http.get('/rest/gscinsights/getcustomer', { params: { websiteid: h.siteId() } }).then(function (res) { // sc-domain:x.nl is een domein-property (alle subdomeinen en http/https): toon x.nl met het label Domein; // een URL-prefix toont het adres. Wat bij deze site past, staat bovenaan. var site = String(($rootScope.user && $rootScope.user.site && $rootScope.user.site.domain) || '').replace(/^www\./, ''); self.properties = (res.data.data || []).map(function (p, i) { var dom = /^sc-domain:/.test(p.siteUrl); var name = dom ? p.siteUrl.replace(/^sc-domain:/, '') : p.siteUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); var host = name.split('/')[0]; var bare = host.replace(/^www\./, ''); return { url: p.siteUrl, name: name, host: host, kind: dom ? 'domain' : 'url', i: i, match: !!site && (bare === site || (dom && site.slice(-bare.length - 1) === '.' + bare)) }; }).sort(function (a, b) { return (b.match - a.match) || (a.i - b.i); }); }); }, chooseProperty: function (p) { var self = this; $http.get('/rest/gscinsights/setcustomer', { params: { websiteid: h.siteId(), customer: p } }).then(function () { gsciState.cache = {}; self.status = null; self.init(); }); }, // GET /rest/gscinsights/; het vorige antwoord voor hetzelfde adres staat er meteen get: function (what, params, onCached) { var key = what + '|' + JSON.stringify(params); if (gsciState.cache[key] && onCached) { onCached(gsciState.cache[key]); } return $http.get('/rest/gscinsights/' + what, { params: angular.extend({ websiteid: h.siteId() }, params) }).then(function (res) { gsciState.cache[key] = res.data; return res.data; }); }, // elke pagina in delen, tegelijk opgevraagd; elke kaart vult zich zodra zijn deel er is (placeholders ertussen) PARTS: { overview: ['kpis', 'chart', 'why', 'actions'], topics: ['stats', 'values'], topic: ['main', 'chart', 'extra'], explore: ['summary', 'rows'], tracked: [null] }, load: function () { var self = this; var what = self.tab === 'topics' ? (self.topicId ? 'topic' : 'topics') : self.tab; var params = self.tab === 'explore' ? self.exploreParams() : (self.tab === 'tracked' ? {} : { compare: self.compare }); if (self.topicId) { params.id = self.topicId; } var parts = self.PARTS[what]; if (report) { var dates = $rootScope.reportdates || {}; angular.extend(params, { start: dates.start, end: dates.end }); // een rapportpagina vraagt alleen de delen op die ze toont if (what === 'overview') { parts = report.urlview === 'actions' ? ['actions'] : ['kpis', 'chart', 'why']; } } var key = what + '|' + JSON.stringify(params); if (!self.data || self.data.key !== key) { self.data = { key: key, parts: {} }; } self.loading = false; self.error = null; self.refresh(); parts.forEach(function (part) { var p = part ? angular.extend({ part: part }, params) : params; var show = function (d) { if (self.data.key !== key) { return; } if (d.error) { self.error = d; return; } self.data.parts[part || 'all'] = d; self.refresh(); }; self.get(what, p, show).then(show, function (res) { self.error = { error: res.status === 403 ? 'no access' : 'google', message: (res.data && res.data.error) || '' }; }); }); }, setCompare: function (v) { if (this.compare === v) { return; } this.compare = gsciState.compare = v; this.loading = !this.data; this.load(); }, // alles wat ng-repeat toont, één keer per nieuwe data (geen functies in ng-repeat: eindeloze digest) refresh: function () { var self = this, d = self.data; self.v = {}; if (self.tab === 'overview') { self.v = self.viewOverview(d); } if (self.tab === 'topics' && !self.topicId) { self.v = self.viewTopics(d); } if (self.tab === 'topics' && self.topicId) { self.v = self.viewTopic(d); } if (self.tab === 'tracked') { self.v = d.parts.all ? self.viewTracked(d.parts.all) : { loading: true }; } if (self.tab === 'explore') { self.v = self.viewExplore(d); } }, // ------------------------------------------------------------------ gedeeld kpis: function (now, before, ga4) { return gsciView.kpis(now, before, ga4); }, causeText: function (r) { return gsciView.causeText(r); }, actionText: function (a) { return gsciView.actionText(a); }, exploreHref: function (q) { return gsciView.exploreHref(q); }, // ------------------------------------------------------------------ overzicht viewOverview: function (all) { var self = this, p = all.parts || {}; var v = { kpis: null, chart: null, why: null, actions: null }; // tegels: tot de cijfers er zijn vijf tegels met alleen het label (geen verspringen) v.kpis = p.kpis ? self.kpis(p.kpis.totals.now, p.kpis.totals.before, p.kpis.ga4) : ['clicks', 'impressions', 'ctr', 'position', 'revenue'].map(function (k) { return { label: t('gscinsights.kpi.' + k), loading: true }; }); if (p.chart) { v.chartData = p.chart; angular.extend(v, self.viewOverviewChart(p.chart, self.metric)); } if (p.why) { var w = gsciView.why(p.why.why); v.whyDown = w.down; v.whyText = w.text; v.why = w.rows; } if (p.actions) { v.actions = p.actions.actions.map(function (a) { return { kind: a.kind, text: self.actionText(a), gain: a.gain, value: p.actions.has_revenue && a.value ? f.euro(a.value) : null, href: self.exploreHref(a.query) }; }); } return v; }, // Arjen (2026-10-28): een klik op een tegel zet die waarde in de grafiek. Klikken en vertoningen als staven (met het // merkdeel), CTR en positie als lijn (gemiddelden stapel je niet), GA4 als staven; steeds met vorig jaar erbij. pickMetric: function (key) { this.metric = key; if (this.v.chartData) { angular.extend(this.v, this.viewOverviewChart(this.v.chartData, key)); } }, viewOverviewChart: function (d, metric) { return gsciView.overviewChart(d, metric); }, // ------------------------------------------------------------------ onderwerpen viewTopics: function (all) { var self = this, d = all.parts.stats, val = all.parts.values; // de toestand van de clustering: uit het antwoord, anders al bekend uit de status (meteen goed scherm) var cs = (d && d.clustering) || (val && val.clustering) || (self.status && self.status.clustering) || {}; var v = { state: cs, stepText: cs.step ? t('gscinsights.topics.step.' + cs.step, { done: cs.done, total: cs.total }) : '' }; // bij een stap die lang duurt (het groeperen) staat de balk stil: laat zien dat hij nog loopt if (v.stepText && cs.minutes) { v.stepText += ' · ' + t('gscinsights.topics.minutes', { n: cs.minutes }); } if (cs.state === 'working') { self.poll(); } v.loadingTiles = (cs.state === 'ready' || cs.refreshing) && !(d && d.topics); if (!d || !d.topics) { return v; } var values = (val && val.has_revenue && val.values) || {}; var named = d.topics.filter(function (x) { return x.id !== '_none'; }); var none = d.topics.filter(function (x) { return x.id === '_none'; })[0]; v.heroText = t('gscinsights.topics.hero_text', { queries: f.num(d.total_queries), topics: named.length }); v.heroMeta = cs.completed_at ? t('gscinsights.topics.hero_meta', { date: f.day(cs.completed_at * 1000) }) : ''; v.tiles = named.map(function (x, i) { // de grootste groot, daarna kleiner: 3x2, 2x1, 1x1 var cols = i < 2 ? 3 : (i < 5 ? 2 : 1), rows = i < 2 ? 2 : 1; var up = x.change === null ? null : x.change >= 0; return { id: x.id, name: x.name, cols: cols, rows: rows, big: cols >= 3, sub: x.custom ? t('gscinsights.topics.custom') : (x.intent ? t('gscinsights.topics.intent.' + x.intent) : '') + (x.brand ? ' · ' + t('gscinsights.topics.brand') : ''), clicks: f.num(x.now.clicks), change: x.change, value: values[x.id] ? f.euro(values[x.id]) : null, cls: up === null ? 'bg-white border-gray-200' : (up ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'), href: BASE + '/onderwerpen/' + encodeURIComponent(x.id) }; }); v.none = none ? { clicks: f.num(none.now.clicks), queries: none.queries, href: BASE + '/onderwerpen/_none' } : null; if (d.split) { var colors = { informational: '#2563eb', commercial: '#7c3aed', transactional: '#db2777', navigational: '#0f766e' }; v.intent = Object.keys(d.split.intent).filter(function (k) { return d.split.intent[k] > 0; }).map(function (k) { return { label: t('gscinsights.topics.intent.' + k), pct: d.split.intent[k], color: colors[k] || '#64748b' }; }); v.brand = d.split.brand; } return v; }, // de clustering loopt: elke 5 seconden de toestand, tot hij klaar is (dan de onderwerpen) poll: function () { var self = this; if (self._poll) { return; } self._poll = $timeout(function () { self._poll = null; if ($scope.$$destroyed) { return; } self.get('status', {}).then(function (s) { self.status = s; var cs = s.clustering; if (cs.state === 'working') { if (self.data.parts.stats) { self.data.parts.stats.clustering = cs; } self.refresh(); } else { self.load(); } }); }, 5000); }, startClustering: function () { var self = this; $http.post('/rest/gscinsights/topics_start', { websiteid: h.siteId() }).then(function () { self.status.clustering = { state: 'working', progress: 5, step: 'starting' }; self.data.parts = {}; self.refresh(); }); }, viewTopic: function (all) { var self = this, m = all.parts.main, c = all.parts.chart, x = all.parts.extra; var tp = (m || c || x || {}).topic || {}; var hasRev = x && x.has_revenue; var v = { name: tp.id === '_none' ? t('gscinsights.topics.unassigned') : (tp.name || ''), hasRev: hasRev, chart: null, actions: null, pages: null, keywords: null }; v.badges = [tp.custom ? t('gscinsights.topics.custom') : (tp.intent ? t('gscinsights.topics.intent.' + tp.intent) : null), tp.brand ? t('gscinsights.topics.brand') : (tp.custom || tp.id === '_none' || !tp.id ? null : t('gscinsights.topics.nonbrand'))].filter(Boolean); if (m) { v.queries = t('gscinsights.topics.queries', { n: f.num(m.keywords.length) }); v.kpis = self.kpis(m.totals.now, m.totals.before, null).slice(0, 4); var values = (x && x.values) || {}; v.keywords = m.keywords.map(function (k) { k.value = values[k.query] || 0; return k; }); } else { v.kpis = ['clicks', 'impressions', 'ctr', 'position'].map(function (k) { return { label: t('gscinsights.kpi.' + k), loading: true }; }); } if (hasRev && m) { v.kpis[3] = { label: t('gscinsights.topics.kpi_value'), value: f.euro(x.value_total) }; } if (c) { v.chartData = c; angular.extend(v, self.topicChart(c, self.topicMetric)); v.partialNote = c.months_partial ? t('gscinsights.topics.per_month_partial', { n: f.num(c.months_partial) }) : ''; } if (x) { v.actions = x.actions.map(function (a) { return { kind: a.kind, text: self.actionText(a), gain: a.gain, value: hasRev && a.value ? f.euro(a.value) : null }; }); v.pages = x.pages.map(function (p) { return { url: p.page.replace(/^https?:\/\/[^\/]+/, '') || '/', full: p.page, share: p.share, value: hasRev ? f.euro(p.value) : null, conv: p.conversions !== null ? t('gscinsights.topics.conversions', { n: f.num(p.conversions) }) : null }; }); } return v; }, // Arjen (2026-10-29): ook bij een onderwerp zet een klik op een tegel die waarde in de grafiek per maand; // klikken en vertoningen als staven, CTR en positie als lijn topicMetric: 'clicks', topicPickMetric: function (key) { this.topicMetric = key; if (this.v.chartData) { angular.extend(this.v, this.topicChart(this.v.chartData, key)); } }, topicChart: function (c, metric) { var months = c.months || []; var labels = months.map(function (mm) { return f.month(mm.month); }); var titles = months.map(function (mm) { return f.monthLong(mm.month); }); var values = months.map(function (mm) { return mm[metric] === undefined ? null : mm[metric]; }); var series = [{ label: t('gscinsights.kpi.' + metric), color: '#3b82f6', values: values }]; var out = { chartTitle: t('gscinsights.overview.' + metric + '_per_month'), chartKind: 'bars', chartFmt: null, chartInvert: false }; if (metric === 'ctr' || metric === 'position') { series[0].width = 2.5; angular.extend(out, { chartKind: 'line', chartFmt: metric === 'ctr' ? function (v) { return f.pct(v); } : f.pos, chartInvert: metric === 'position' }); out.chart = { labels: labels, titles: titles, series: series }; } else { out.chart = { labels: labels, titles: titles, series: series, faded: months.map(function (mm, i) { return i === months.length - 1; }) }; } return out; }, kwSort: { key: 'clicks', desc: true }, kwLimit: 25, // volgen of niet (vanuit Onderwerpen en Verkennen); de ster verandert meteen toggleTrack: function (r) { var self = this; r.tracked = !r.tracked; $http.post('/rest/gscinsights/track', { websiteid: h.siteId(), keywords: [r.query], action: r.tracked ? 'add' : 'remove' }).then(function () { // Gevolgde zoekwoorden opnieuw ophalen bij het volgende bezoek Object.keys(gsciState.cache).forEach(function (k) { if (k.indexOf('tracked|') === 0) { delete gsciState.cache[k]; } }); }); }, // ------------------------------------------------------------------ gevolgde zoekwoorden viewTracked: function (d) { var self = this, months = d.months || []; var all = [], top3 = 0, top3y = 0, clicks = 0, clicksY = 0, since = null, withYear = false; (d.groups || []).forEach(function (g) { g.keywords.forEach(function (k) { all.push(k); clicks += k.now.clicks; if (k.year) { clicksY += k.year.clicks; withYear = true; if (k.year.position && k.year.position <= 3) { top3y++; } } if (k.now.position && k.now.position <= 3) { top3++; } if (k.since && (!since || k.since < since)) { since = k.since; } k.sparkLabels = months.map(function (m) { return f.monthLong(m); }); k.measuredText = k.measured ? (k.measured.pos ? String(k.measured.pos) : t('gscinsights.tracked.not_top20')) : '–'; // Arjen (2026-10-28): geen kolom "Vorig jaar" meer; vorig jaar in de tooltip bij Klikken, en Trend // (positie tegenover vorig jaar) in plaats van Gezondheid k.clickTip = { t: k.query, r: [{ label: t('gscinsights.tracked.last_28'), value: f.num(k.now.clicks) }, { label: t('gscinsights.tracked.col.last_year'), value: k.year ? f.num(k.year.clicks) : '–' }] }; k.trend = self.trend(k); }); }); var histMonths = since ? Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / (30.44 * 86400000))) : 0; return { months: months, count: all.length, kpis: [ { label: t('gscinsights.tracked.kpi_clicks'), value: f.num(clicks), delta: withYear && clicksY ? 100 * (clicks - clicksY) / clicksY : null, note: t('gscinsights.vs_year') }, { label: t('gscinsights.tracked.kpi_top3'), value: top3 + ' ' + t('gscinsights.tracked.of', { n: all.length }), note: withYear ? t('gscinsights.vs_year') + ': ' + top3y : '' }, { label: t('gscinsights.tracked.kpi_history'), value: t('gscinsights.tracked.months', { n: histMonths }), note: t('gscinsights.tracked.kpi_history_note') } ], groups: (d.groups || []).map(function (g) { // de kolom Gemeten alleen als een zoekwoord in de groep in de Rank tracker staat return { name: g.name || t('gscinsights.tracked.no_group'), raw: g.name, change: g.change, sum: t('gscinsights.tracked.group_sum', { n: g.keywords.length, clicks: f.num(g.clicks) }), keywords: g.keywords, hasMeasured: g.keywords.some(function (k) { return !!k.measured; }) }; }), groupNames: (d.groups || []).map(function (g) { return g.name; }).filter(Boolean) }; }, // stijgt, daalt of stabiel: de positie van nu tegenover dezelfde 28 dagen vorig jaar (10%, minstens 1 plaats) trend: function (k) { var now = k.now && k.now.position, ly = k.year && k.year.position; if (!now) { return null; } if (!ly) { return { key: 'new', cls: 'bg-blue-50 text-blue-700', tip: { t: t('gscinsights.tracked.col.trend'), r: [{ label: t('gscinsights.tracked.trend_new'), value: '' }] } }; } var d = ly - now, lim = Math.max(1, ly * 0.1); var key = d > lim ? 'up' : (d < -lim ? 'down' : 'flat'); return { key: key, cls: { up: 'bg-green-50 text-green-700', down: 'bg-red-50 text-red-700', flat: 'bg-gray-100 text-gray-600' }[key], tip: { t: t('gscinsights.tracked.col.trend'), r: [{ label: t('gscinsights.tracked.trend_why', { from: f.pos(ly), to: f.pos(now) }), value: '' }] } }; }, selectedAny: function () { return this.selected().length > 0; }, // ------------------------------------------------------------------ uitklap (Gevolgd en Verkennen) // één zoekwoord of pagina openklappen, in Gevolgd en Verkennen dezelfde uitklap (gscinsights/detail): grafiek // (positie, klikken, vertoningen, CTR; per dag of per maand) en daaronder de pagina's (bij een pagina: de // zoekwoorden), landen en apparaten met dezelfde waarde als de grafiek open: null, // vast per soort (geen array-literal in ng-repeat: die maakt elke digest nieuwe objecten) DETAIL_BLOCKS: { page: [{ k: 'page', t: 'gscinsights.explore.dim.page' }, { k: 'country', t: 'gscinsights.explore.dim.country' }, { k: 'device', t: 'gscinsights.explore.dim.device' }], query: [{ k: 'query', t: 'gscinsights.explore.dim.query' }, { k: 'country', t: 'gscinsights.explore.dim.country' }, { k: 'device', t: 'gscinsights.explore.dim.device' }] }, detailMetrics: ['position', 'clicks', 'impressions', 'ctr'], DEVICE_ICON: { DESKTOP: 'fa-desktop', MOBILE: 'fa-mobile', TABLET: 'fa-tablet' }, isOpen: function (kind, key) { return !!this.open && this.open.kind === kind && this.open.key === key; }, toggleDetail: function (kind, key, clicks) { var self = this; if (self.isOpen(kind, key)) { self.open = null; return; } // in Verkennen met de filters en de periode van daar var p = self.tab === 'explore' ? self.exploreParams() : {}; delete p.dim; p[kind] = key; // zonder klikken opent de grafiek op de positie (een lijn op 0 zegt niets) var o = self.open = { kind: kind, key: key, metric: clicks > 0 ? 'clicks' : 'position', per: 'month', d: null }; var show = function (d) { if (self.open !== o) { return; } var l = d.lists || {}; (l.page || []).forEach(function (r) { r.label = r.page.replace(/^https?:\/\/[^\/]+/, '') || '/'; }); (l.query || []).forEach(function (r) { r.label = r.query; }); (l.country || []).forEach(function (r) { r.label = self.countryName(r.cc) || r.country; r.flag = r.cc ? '/static/img/flags/' + String(r.cc).toLowerCase() + '.svg' : null; }); (l.device || []).forEach(function (r) { r.label = t('gscinsights.explore.device_name.' + r.device); r.icon = self.DEVICE_ICON[r.device] || 'fa-question'; }); o.d = d; o.blocks = self.DETAIL_BLOCKS[d.by] || self.DETAIL_BLOCKS.page; self.detailChart(); }; self.get('detail', p, show).then(show); }, detailSet: function (key, val) { this.open[key] = val; this.detailChart(); }, // nieuw data-object voor mt-line bij elke keuze, en de lijstjes eronder gesorteerd op dezelfde waarde detailChart: function () { var o = this.open, d = o && o.d, m = o && o.metric; if (!d) { return; } var rows = o.per === 'day' ? d.daily : d.monthly; // jaartal bij januari (24 maanden: anders twee keer nov … okt) var lbl = function (r) { return o.per === 'day' ? f.dayWeek(r.date) : (r.month.slice(5) === '01' ? f.month(r.month) + ' ’' + r.month.slice(2, 4) : f.month(r.month)); }; var labels = rows.map(lbl); var series = [{ label: t('gscinsights.explore.col.' + m) + (m === 'position' && d.measured ? ' (Search Console)' : ''), color: '#2563eb', width: 2.25, nodots: true, values: rows.map(function (r) { return r[m]; }) }]; if (m === 'position' && d.measured) { series.push({ label: t('gscinsights.tracked.measured_line'), color: '#f59e0b', dash: true, connect: true, nodots: true, values: rows.map(function (r) { return r.measured === undefined ? null : r.measured; }) }); } o.chart = { labels: o.per === 'day' ? labels.map(function (l, i) { return i % 14 === 0 ? l : ''; }) : labels, titles: rows.map(function (r) { return o.per === 'day' ? f.dayWeek(r.date) : f.monthLong(r.month); }), series: series }; o.fmt = m === 'ctr' ? function (v) { return f.pct(v); } : (m === 'position' ? function (v) { return f.pos(v); } : null); o.ints = m === 'clicks' || m === 'impressions'; o.valueFmt = m === 'ctr' ? function (v) { return f.pct(v); } : (m === 'position' ? f.pos : f.num); // gesorteerd op de gekozen waarde (positie: de beste eerst) o.lists = {}; angular.forEach(d.lists || {}, function (list, k) { o.lists[k] = list.slice().sort(function (a, b) { return m === 'position' ? (a.position || 999) - (b.position || 999) : (b[m] || 0) - (a[m] || 0); }); }); }, // klik op een pagina in de uitklap: Verkennen op die pagina (vanuit Gevolgd via het adres) detailPage: function (url) { if (this.tab === 'explore') { this.exFilterPage(url); return; } $location.path('/search-insights/verkennen').search({ page: url, page_op: 'equals' }); }, add: { show: false, text: '', group: '', busy: false }, bulk: { group: '' }, openAdd: function () { this.add = { show: true, text: '', group: '', busy: false }; }, saveAdd: function () { var self = this; var kws = self.add.text.split(/\n/).map(function (s) { return s.trim(); }).filter(Boolean); if (!kws.length) { return; } self.add.busy = true; $http.post('/rest/gscinsights/track', { websiteid: h.siteId(), keywords: kws, action: 'add', group: self.add.group }).then(function () { self.add = { show: false, text: '', group: '', busy: false }; self.load(); }, function () { self.add.busy = false; }); }, selected: function () { var out = []; angular.forEach(this.v.groups || [], function (g) { g.keywords.forEach(function (k) { if (k.sel) { out.push(k.query); } }); }); return out; }, bulkGroup: function () { var self = this, kws = self.selected(); if (!kws.length) { return; } $http.post('/rest/gscinsights/group', { websiteid: h.siteId(), keywords: kws, group: self.bulk.group }).then(function () { self.bulk.group = ''; self.load(); }); }, bulkRemove: function () { var self = this, kws = self.selected(); if (!kws.length) { return; } $http.post('/rest/gscinsights/track', { websiteid: h.siteId(), keywords: kws, action: 'remove' }).then(function () { self.load(); }); }, // ------------------------------------------------------------------ verkennen ex: gsciState.explore || { dim: 'query', q: '', q_op: 'contains', page: '', page_op: 'contains', country: '', device: '', brand: '', days: 28, compare: 'prev' }, exQuick: '', exSort: { key: 'clicks', desc: true }, exLimit: 100, exploreParams: function () { var p = {}; angular.forEach(this.ex, function (v, k) { if (v !== '' && v !== null) { p[k] = v; } }); return p; }, exChanged: function (now) { var self = this; gsciState.explore = self.ex; if (self._exTimer) { $timeout.cancel(self._exTimer); } self._exTimer = $timeout(function () { self.exLimit = 100; self.open = null; self.load(); }, now ? 0 : 500); }, exReset: function () { this.ex = { dim: this.ex.dim, q: '', q_op: 'contains', page: '', page_op: 'contains', country: '', device: '', brand: '', days: 28, compare: 'prev' }; this.exQuick = ''; this.exChanged(true); }, exDim: function (d) { this.ex.dim = d; this.exQuick = ''; this.exSort = { key: d === 'date' ? 'date' : 'clicks', desc: true }; this.exChanged(true); }, setQuick: function (q) { this.exQuick = this.exQuick === q ? '' : q; this.refresh(); }, // Arjen (2026-10-29): ook in Verkennen zet een klik op een tegel die waarde in de grafiek per dag exMetric: 'clicks', exPickMetric: function (key) { this.exMetric = key; if (this.v.summaryData) { angular.extend(this.v, this.exploreChart(this.v.summaryData, key)); } }, exploreChart: function (sm, metric) { var labels = (sm.daily || []).map(function (r) { return f.dayWeek(r.date); }); var pick = function (r) { return r[metric] === undefined ? null : r[metric]; }; var series = [{ label: t('gscinsights.explore.this_period'), color: '#2563eb', values: (sm.daily || []).map(pick), strong: true }]; if (sm.daily_before) { series.push({ label: t('gscinsights.explore.prev_period'), color: '#9ca3af', dash: true, values: sm.daily_before.map(pick) }); } return { chart: { labels: labels.length > 12 ? labels.map(function (l, i) { return i % Math.ceil(labels.length / 8) === 0 ? l : ''; }) : labels, titles: labels, series: series }, chartFmt: metric === 'ctr' ? function (v) { return f.pct(v); } : metric === 'position' ? f.pos : null, chartInvert: metric === 'position' }; }, viewExplore: function (all) { var self = this, ex = self.ex, sm = all.parts.summary, d = all.parts.rows; var v = { longNoCompare: ex.days > 180, chart: null, rows: null }; if (sm) { v.kpis = self.kpis(sm.totals.now, sm.totals.before || null, undefined).slice(0, 4); v.summaryData = sm; angular.extend(v, self.exploreChart(sm, self.exMetric)); } else { v.kpis = ['clicks', 'impressions', 'ctr', 'position'].map(function (k) { return { label: t('gscinsights.kpi.' + k), loading: true }; }); } if (!d) { return v; } if (ex.dim === 'newlost') { v.newLost = [{ k: 'new', rows: d['new'] || [] }, { k: 'lost', rows: d.lost || [] }]; return v; } var rows = d.rows || []; var Q = /^(wie|wat|waar|wanneer|waarom|hoe|welke|welk|kan|kun|mag|moet|is|zijn|heb|how|what|why|when|where|which|who|can|is|does|do)\b/i; if (self.exQuick === 'chance') { rows = rows.filter(function (r) { return r.position >= 4 && r.position <= 20 && r.impressions >= 100; }); } if (self.exQuick === 'lowctr') { rows = rows.filter(function (r) { return r.position <= 3 && r.impressions >= 200 && r.ctr < 5; }); } if (self.exQuick === 'down') { rows = rows.filter(function (r) { return r.change !== null && r.change < 0; }); } if (self.exQuick === 'questions') { rows = rows.filter(function (r) { return Q.test(r.query || ''); }); } rows.forEach(function (r) { if (ex.dim === 'page') { r.label = r.page.replace(/^https?:\/\/[^\/]+/, '') || '/'; } else if (ex.dim === 'device') { r.label = t('gscinsights.explore.device_name.' + r.device); } else if (ex.dim === 'country') { r.label = self.countryName(r.cc) || r.country; } else if (ex.dim === 'date') { r.label = f.dayWeek(r.date); } else { r.label = r.query; } }); v.rows = rows; v.total = d.total_rows; v.ga4 = ex.dim === 'page' && d.ga4; v.hasRev = d.has_revenue; return v; }, countryName: function (cc) { try { return cc ? new Intl.DisplayNames([$translate.use() || 'nl'], { type: 'region' }).of(cc) : ''; } catch (e) { return cc; } }, // klik op een pagina in de uitklap: filter Verkennen op die pagina exFilterPage: function (url) { this.ex.page = url; this.ex.page_op = 'equals'; this.ex.dim = 'query'; this.exChanged(true); }, exExport: function () { var rows = this.v.rows || [], dim = this.ex.dim; var cols = [dim, 'clicks', 'impressions', 'ctr', 'position']; if (this.v.ga4) { cols = cols.concat(['sessions', 'conversions', 'revenue']); } var esc = function (x) { x = x === null || x === undefined ? '' : String(x); return /[",\n;]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; var csv = cols.join(',') + '\n' + rows.map(function (r) { return cols.map(function (k) { return esc(r[k]); }).join(','); }).join('\n'); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); a.download = 'search-console-' + dim + '-' + new Date().toISOString().substr(0, 10) + '.csv'; a.click(); } }; // Verkennen: filters uit het adres (een link vanuit Overzicht, een onderwerp of het dashboard) if (c.tab === 'explore') { var qs = $location.search(); if (qs.q !== undefined || qs.page !== undefined || qs.dim || qs.country || qs.device || qs.sort) { var dimOk = ['query', 'page', 'country', 'device', 'date', 'newlost'].indexOf(qs.dim) > -1; c.ex = { dim: dimOk ? qs.dim : 'query', q: qs.q || '', q_op: qs.q_op || 'contains', page: qs.page || '', page_op: qs.page_op || 'contains', country: /^[a-z]{3}$/.test(qs.country || '') ? qs.country : '', device: ['desktop', 'mobile', 'tablet'].indexOf(qs.device) > -1 ? qs.device : '', brand: '', days: 28, compare: 'prev' }; gsciState.explore = c.ex; // sorteren op een kolom (een tegel op het dashboard); positie: laag is goed if (['clicks', 'impressions', 'ctr', 'position', 'sessions', 'conversions', 'revenue'].indexOf(qs.sort) > -1) { c.exSort = { key: qs.sort, desc: qs.sort !== 'position' }; } else if (c.ex.dim === 'date') { c.exSort = { key: 'date', desc: true }; } if (['clicks', 'impressions', 'ctr', 'position'].indexOf(qs.sort) > -1) { c.exMetric = qs.sort; } } } $scope.$on('$destroy', function () { if (c._poll) { $timeout.cancel(c._poll); } }); // de teksten worden in de controller vertaald (instant): pas beginnen als de vertalingen er zijn $translate.onReady().then(function () { c.init(); }); }]); // AI Insights: /ai-insights (overzicht), /ai-insights/antwoorden(/:id), /concurrenten, /verkeer. // Word je genoemd door ChatGPT en Gemini (onze metingen), komen ze (GA4) en wat levert het op. // Server: API v1 (/rest/v1/aiinsights.*, rest/include/aiinsights/api.php), regels in rest/include/aiinsights/rules.php. // Ontwerp: docs/superpowers/specs/2026-10-27-ai-insights-design.md. Het antwoord van de API krijgt op één plek (aiView.fromApi // en de functies fromApi* hieronder) de vorm die de sjablonen tonen. // Bouwblokken: js/ui.js (docs/ui.md). Nog niet gelanceerd: zichtbaar via user.site.show.aiinsights. // wat bewaard blijft tussen de pagina's: de laatst opgehaalde delen (meteen tonen, op de achtergrond vernieuwen) var aiState = { site: null, cache: {}, pending: {} }; // AI Insights in tegels, grafiek en "wat nu"; gedeeld met het Dashboard (js/controller/home.js). app.factory('aiView', ['$translate', 'mtFmt', function ($translate, mtFmt) { var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; var BASE = '/ai-insights'; // AI in het bezoek (GA4) => kleur; dezelfde volgorde als in de grafiek var COLORS = { chatgpt: '#1e3a8a', perplexity: '#3b82f6', gemini: '#93c5fd', other: '#cbd5e1' }; var NAMES = { chatgpt: 'ChatGPT', perplexity: 'Perplexity', gemini: 'Gemini', copilot: 'Copilot', claude: 'Claude', deepseek: 'DeepSeek', meta: 'Meta AI', grok: 'Grok', mistral: 'Mistral', you: 'You.com', phind: 'Phind', poe: 'Poe', openai: 'ChatGPT', google: 'Gemini', other: '' }; var view = { COLORS: COLORS, NAMES: NAMES, name: function (e) { return NAMES[e] || e; }, promptHref: function (id) { return BASE + '/antwoorden/' + encodeURIComponent(id); }, // de tegels: genoemd, als bron (aiinsights.summary), bezoek uit AI, opbrengst of conversies (aiinsights.traffic_summary); // zonder gegevens vier lege tegels, zonder bezoekcijfers (nog bezig) die twee lege tegels kpis: function (s, tr) { var loading = function (x) { return { label: t('aiinsights.kpi.' + x), loading: true }; }; if (!s) { return ['mentioned', 'linked', 'visits', 'revenue'].map(loading); } var noPrompts = !s.answers; var out = [ { label: t('aiinsights.kpi.mentioned'), value: noPrompts ? '–' : f.pct(s.mentioned_pct.value, 0), delta: noPrompts ? null : s.mentioned_pct.change, unit: 'pt', note: noPrompts ? t('aiinsights.kpi.no_answers') : t('aiinsights.kpi.mentioned_note', { n: s.mentioned, total: s.answers }) }, { label: t('aiinsights.kpi.linked'), value: noPrompts ? '–' : f.pct(s.linked_pct.value, 0), delta: noPrompts ? null : s.linked_pct.change, unit: 'pt', note: noPrompts ? '' : t('aiinsights.kpi.linked_note', { n: s.linked, total: s.answers }) } ]; if (!tr) { return out.concat(['visits', 'revenue'].map(loading)); } if (!tr.ga4_connected) { return out.concat([{ label: t('aiinsights.kpi.visits'), value: '–', note: t('aiinsights.kpi.no_ga4') }, { label: t('aiinsights.kpi.revenue'), value: '–', note: t('aiinsights.kpi.no_ga4') }]); } out.push({ label: t('aiinsights.kpi.visits'), value: f.num(tr.sessions.value), delta: tr.sessions.change_pct, note: tr.ai_share_pct !== null ? t('aiinsights.kpi.share', { pct: f.pct(tr.ai_share_pct) }) : '' }); out.push(tr.revenue_eur.value >= 10 || tr.revenue_eur.previous >= 10 ? { label: t('aiinsights.kpi.revenue'), value: f.euro(tr.revenue_eur.value), delta: tr.revenue_eur.change_pct, note: t('aiinsights.kpi.conversions_note', { n: f.num(tr.conversions.value) }) } : { label: t('aiinsights.kpi.conversions'), value: f.num(tr.conversions.value), delta: tr.conversions.change_pct, note: t('aiinsights.kpi.from_ai') }); return out; }, // AI-bezoek per maand per AI (aiinsights.traffic_monthly); null zonder GA4 chart: function (months, ga4) { var self = view, main = ['chatgpt', 'perplexity', 'gemini']; if (!ga4) { return null; } var of = function (m, e) { return (m.per_engine || []).reduce(function (n, x) { return n + ((e === 'other' ? main.indexOf(x.key) === -1 : x.key === e) ? x.sessions : 0); }, 0); }; return { labels: months.map(function (m) { return f.month(m.month); }), titles: months.map(function (m) { return f.monthLong(m.month) + (!m.complete ? ' · ' + t('aiinsights.chart.partial') : ''); }), series: ['other', 'gemini', 'perplexity', 'chatgpt'].map(function (e) { return { label: e === 'other' ? t('aiinsights.engines.other') : self.name(e), color: COLORS[e], values: months.map(function (m) { return of(m, e); }) }; }), faded: months.map(function (m) { return !m.complete; }), extra: months.map(function (m) { return m.revenue_eur ? [{ label: t('aiinsights.kpi.revenue'), value: f.euro(m.revenue_eur), color: '#047857' }] : []; }) }; }, // wat nu (aiinsights.actions), met de link erbij actions: function (list) { var self = view; return list.map(function (x) { var o = { kind: x.kind }; if (x.kind === 'not_mentioned') { o.text = t('aiinsights.action.not_mentioned', { q: x.prompt, others: x.others.join(', ') }); o.href = self.promptHref(x.prompt_id); o.go = t('aiinsights.goto.prompt'); } if (x.kind === 'lost') { o.text = t('aiinsights.action.lost', { q: x.prompt, engine: self.name(x.engine) }); o.href = self.promptHref(x.prompt_id); o.go = t('aiinsights.goto.prompt'); } if (x.kind === 'no_conversion') { o.text = t('aiinsights.action.no_conversion', { page: x.path, n: f.num(x.sessions) }); o.href = BASE + '/verkeer'; o.go = t('aiinsights.tab.traffic'); } if (x.kind === 'idea') { o.text = t('aiinsights.action.idea', { q: x.query, n: f.num(x.impressions) }); o.href = BASE + '/antwoorden?add=' + encodeURIComponent(x.query); o.go = t('aiinsights.goto.add'); } return o; }); } }; return view; }]); app.controller('aiinsights', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$q', '$translate', '$timeout', '$sce', 'h', 'mtFmt', 'aiView', function ($scope, $rootScope, $location, $routeParams, $http, $q, $translate, $timeout, $sce, h, mtFmt, aiView) { // in een rapport (newlayout/aiinsights/report.html): de maand van het rapport, alleen lezen var report = $scope.custominit && $scope.custominit.ai ? $scope.custominit : null; if (!report && (!$rootScope.user || !$rootScope.user.site || !$rootScope.user.site.show || !$rootScope.user.site.show.aiinsights)) { $location.path('/'); return; } var TABS = { '': 'overview', antwoorden: 'answers', concurrenten: 'competitors', verkeer: 'traffic' }; var BASE = '/ai-insights'; var ENGINES = ['openai', 'google']; // de API noemt een AI zoals in het bezoek (chatgpt, gemini); de pagina intern zoals de metingen (openai, google) var KEY = { openai: 'chatgpt', google: 'gemini' }; var INTERNAL = { chatgpt: 'openai', gemini: 'google' }; // AI in het bezoek (GA4) => kleur; dezelfde volgorde als in de grafiek var COLORS = aiView.COLORS; var TRAFFIC_OF = { openai: 'chatgpt', google: 'gemini', perplexity: 'perplexity' }; var NAMES = aiView.NAMES; // een week in de rij van 12: kleur per status var WEEK = { linked: 'bg-green-600', mentioned: 'bg-blue-400', none: 'bg-gray-200', nosearch: 'bg-amber-200' }; // het pakket in metingen per ronde, één meting = één prompt bij één AI (zelfde lijsten als ai::PLAN_FREQ en // ai::ANSWER_STEPS in rest/include/ai.php) var FREQS = ['monthly', 'biweekly', 'weekly']; var ANSWER_STEPS = [30, 75, 150, 300, 600]; // de AI's van de vorige nieuwe prompt, als beginkeuze voor de volgende (per browser) var LAST_ENGINES = 'mt.aiinsights.engines'; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (aiState.site !== h.siteId()) { aiState = { site: h.siteId(), cache: {}, pending: {} }; } var esc = function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }; var a = $scope.a = { f: f, tab: report ? report.urlview : (TABS[$routeParams.tab || ''] || 'overview'), promptId: report ? null : ($routeParams.id || null), report: !!report, status: aiState.cache.status || null, data: null, loading: true, error: null, v: {}, weekCls: WEEK, freqs: FREQS, answerSteps: ANSWER_STEPS, init: function () { var self = this; self.tabs = [ { key: 'overview', label: t('aiinsights.tab.overview'), href: BASE }, { key: 'answers', label: t('aiinsights.tab.answers'), href: BASE + '/antwoorden' }, { key: 'competitors', label: t('aiinsights.tab.competitors'), href: BASE + '/concurrenten' }, { key: 'traffic', label: t('aiinsights.tab.traffic'), href: BASE + '/verkeer' } ]; var args = self.args('summary'); var cached = aiState.cache[self.callKey('aiinsights.summary', args)]; if (cached) { self.status = fromApiStatus(cached); } self.api('aiinsights.summary', args).then(function (s) { self.status = fromApiStatus(s); self.load(); // een idee uit "Wat nu" (?add=vraag): meteen het formulier open if ($location.search().add && self.tab === 'answers' && s.can_write) { self.openEdit(null, $location.search().add); $location.search('add', null); } }, function (res) { self.loading = false; self.error = { error: res.status === 403 ? 'no access' : 'server', message: self.errorText(res) }; }); }, // de periode: in een rapport de maand van het rapport, anders de standaard van de API (28 dagen tot gisteren) period: function () { if (!report) { return {}; } var dates = $rootScope.reportdates || {}; var day = function (v) { v = +v; if (!v) { return null; } var d = new Date(v > 10000000000 ? v : v * 1000); return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }; var from = day(dates.start), to = day(dates.end); return from && to ? { from: from, to: to } : {}; }, // de argumenten van een stuk van de API args: function (name) { var p = this.period(); if (name === 'summary' || name === 'traffic_summary' || name === 'actions') { return p; } if (name === 'traffic_pages') { return angular.extend({ limit: 500 }, p); } if (name === 'traffic_monthly') { return p.to ? { to: p.to } : {}; } if (name === 'prompt') { return { prompt_id: this.promptId }; } return {}; }, callKey: function (name, args) { return name + '|' + JSON.stringify(args); }, errorText: function (res) { return (res && res.data && res.data.error && res.data.error.message) || ''; }, // API v1 (/rest/v1/) -> data; het vorige antwoord voor dezelfde aanroep blijft bewaard, een aanroep die al // loopt wordt gedeeld (de samenvatting is er voor de kop en het overzicht maar één keer) api: function (name, args) { var key = this.callKey(name, args); if (!aiState.pending[key]) { aiState.pending[key] = $http.post('/rest/v1/' + name, angular.extend({ site_id: h.siteId() }, args)).then(function (res) { aiState.cache[key] = res.data.data; return res.data.data; }).finally(function () { delete aiState.pending[key]; }); } return aiState.pending[key]; }, // elke pagina in delen, tegelijk opgevraagd; een deel = een of meer stukken van de API, en elke kaart vult zich // zodra zijn deel er is PARTS: { overview: { main: ['summary', 'traffic_summary'], chart: ['traffic_monthly', 'summary'], actions: ['actions'] }, answers: { all: ['prompts', 'ai_overviews', 'summary'] }, prompt: { all: ['prompt'] }, competitors: { all: ['competitors', 'summary'] }, traffic: { all: ['traffic_summary', 'traffic_pages', 'summary'] } }, load: function () { var self = this; var what = self.tab === 'answers' && self.promptId ? 'prompt' : self.tab; var key = what + '|' + (self.promptId || '') + '|' + JSON.stringify(self.period()); if (!self.data || self.data.key !== key) { self.data = { key: key, parts: {} }; } self.loading = false; self.error = null; self.refresh(); angular.forEach(self.PARTS[what], function (names, part) { var calls = names.map(function (n) { return ['aiinsights.' + n, self.args(n)]; }); var show = function (list) { if (self.data.key !== key) { return; } self.data.parts[part] = list; self.refresh(); }; var cached = calls.map(function (c) { return aiState.cache[self.callKey(c[0], c[1])]; }); if (cached.every(function (x) { return x !== undefined; })) { show(cached); } $q.all(calls.map(function (c) { return self.api(c[0], c[1]); })).then(show, function (res) { self.error = { error: res.status === 403 ? 'no access' : (res.status === 404 ? 'not found' : 'server'), message: self.errorText(res) }; }); }); }, // alles wat ng-repeat toont, één keer per nieuwe data (geen functies in ng-repeat: eindeloze digest) refresh: function () { var self = this, d = self.data, all = d.parts.all; if (self.tab === 'overview') { self.v = self.viewOverview(d.parts); } if (self.tab === 'answers' && !self.promptId) { self.v = all ? self.viewAnswers(fromApiAnswers(all[0], all[1], all[2])) : { loading: true }; } if (self.tab === 'answers' && self.promptId) { self.v = all ? self.viewPrompt(fromApiPrompt(all[0])) : { loading: true }; } if (self.tab === 'competitors') { self.v = all ? self.viewCompetitors(fromApiCompetitors(all[0], all[1])) : { loading: true }; } if (self.tab === 'traffic') { self.v = all ? self.viewTraffic(fromApiTraffic(all[0], all[1], all[2])) : { loading: true }; } }, // ------------------------------------------------------------------ gedeeld name: function (e) { return aiView.name(e); }, pct: function (n, of) { return of ? 100 * n / of : null; }, ofText: function (n, of) { return t('aiinsights.of', { n: f.num(n), total: f.num(of) }); }, promptHref: function (id) { return aiView.promptHref(id); }, // ------------------------------------------------------------------ overzicht viewOverview: function (p) { var self = this, v = {}; var sum = p.main ? p.main[0] : null, tr = p.main ? p.main[1] : null; v.kpis = aiView.kpis(sum, tr); if (sum) { // per AI die wij bevragen: genoemd en als bron (onze metingen), bezoek uit die AI (GA4) var traffic = tr && tr.ga4_connected ? tr.per_engine : null; var by = {}; (traffic || []).forEach(function (x) { by[x.key] = x; }); var asked = sum.per_engine.map(function (e) { return e.key; }); v.engines = sum.per_engine.map(function (e) { var x = traffic ? (by[e.key] || { sessions: 0, conversions: 0, revenue_eur: 0 }) : null; return { name: e.label, sub: e.answers ? t('aiinsights.engines.measured', { n: e.answers }) : t('aiinsights.engines.not_measured'), mentioned: e.answers ? self.pct(e.mentioned, e.answers) : null, mentionedText: e.answers ? self.ofText(e.mentioned, e.answers) : '–', linkedText: e.answers ? self.ofText(e.linked, e.answers) : '–', sessions: x ? f.num(x.sessions) : '–', conversions: x ? f.num(x.conversions) : '–', revenue: x && x.revenue_eur ? f.euro(x.revenue_eur) : '–' }; }); // bezoek uit AI's die wij niet bevragen (Perplexity, Copilot, Claude ...) if (traffic) { var rest = traffic.filter(function (x) { return asked.indexOf(x.key) === -1; }); var total = function (k) { return rest.reduce(function (n, x) { return n + x[k]; }, 0); }; v.engines.push({ name: rest.length ? rest.map(function (x) { return x.label; }).join(', ') : t('aiinsights.engines.other'), sub: t('aiinsights.engines.other_note'), mentioned: null, mentionedText: '–', linkedText: '–', sessions: f.num(total('sessions')), conversions: f.num(total('conversions')), revenue: total('revenue_eur') ? f.euro(total('revenue_eur')) : '–' }); } } if (p.chart) { v.noGa4 = !p.chart[1].ga4_connected; v.chart = aiView.chart(p.chart[0], p.chart[1].ga4_connected); } if (p.actions) { v.actions = aiView.actions(p.actions[0]); } return v; }, // ------------------------------------------------------------------ AI-antwoorden viewAnswers: function (d) { var self = this; return { weeks: d.weeks.map(function (w) { return f.day(w); }), count: d.prompts.length, plan: d.plan, used: d.used, planPct: Math.min(100, 100 * d.used / d.plan.answers), canPlan: d.can_plan || d.can_buy, rows: d.prompts.map(function (p) { return angular.extend(p, { href: self.promptHref(p.id), cellList: ENGINES.map(function (e) { var c = p.cells[e]; return { status: c ? c.status : '', pos: c && c.pos ? c.pos : '', off: !c }; }), weekList: p.weeks.map(function (s, i) { return { cls: s ? WEEK[s] : 'bg-gray-50 border border-gray-200', title: f.day(d.weeks[i]) + ': ' + (s ? t('aiinsights.week.' + s) : t('aiinsights.week.none_measured')) }; }), whoText: p.who.join(', '), errorText: p.paused ? '' : (p.errors.length ? t('aiinsights.answers.error', { engines: p.errors.map(function (e) { return self.name(e); }).join(', ') }) : '') }); }), aio: d.aio, aioRows: d.aio.rows.map(function (r) { return angular.extend(r, { status: r.pos ? 'linked' : 'none', whoText: r.who.join(', ') }); }) }; }, // prompt toevoegen of wijzigen edit: { show: false }, openEdit: function (p, q) { var last = null; try { last = JSON.parse(window.localStorage.getItem(LAST_ENGINES)); } catch (e) { last = null; } var on = p ? p.providers : (angular.isArray(last) && last.length ? last : ENGINES); this.edit = { show: true, busy: false, error: '', id: p ? p.id : null, q: p ? p.q : (q || ''), providers: { openai: on.indexOf('openai') > -1, google: on.indexOf('google') > -1 }, // wat deze prompt nu al van het pakket gebruikt (bij wijzigen telt die ruimte als vrij) was: p ? p.providers.length : 0, brands: p ? p.brands.join(', ') : '' }; }, // metingen van de prompt in het formulier, en wat er daarna nog vrij is (ai::weight: geen keuze = alle AI's) editWeight: function () { var e = this.edit; return ENGINES.filter(function (x) { return e.providers[x]; }).length || ENGINES.length; }, editLeft: function () { return this.v.plan ? this.v.plan.answers - this.v.used + this.edit.was - this.editWeight() : null; }, saveEdit: function () { var self = this, e = self.edit; e.busy = true; e.error = ''; if (!e.id) { try { window.localStorage.setItem(LAST_ENGINES, JSON.stringify(ENGINES.filter(function (x) { return e.providers[x]; }))); } catch (err) { /* geen opslag: niet erg */ } } var body = { site_id: h.siteId(), prompt: e.q, engines: ENGINES.filter(function (x) { return e.providers[x]; }).map(function (x) { return KEY[x]; }), brand_terms: e.brands.split(',').map(function (b) { return b.trim(); }).filter(Boolean) }; if (e.id) { body.prompt_id = e.id; } $http.post('/rest/v1/aiinsights.prompt_save', body).then(function () { // een nieuwe prompt: de melding "nog geen prompts" (bovenaan elke tab) moet meteen weg if (!e.id && self.status) { self.status.prompts = (self.status.prompts || 0) + 1; } self.edit = { show: false }; aiState.cache = {}; self.load(); }, function (res) { e.busy = false; var err = (res.data && res.data.error && res.data.error.code) || 'server'; // het pakket is vol: hoeveel er past en hoeveel er (zonder deze prompt) nog vrij is var st = self.status || {}, limit = st.plan ? st.plan.answers : 0; e.error = t('aiinsights.edit.error.' + (['limit', 'exists', 'empty'].indexOf(err) > -1 ? err : 'server'), { limit: limit, left: Math.max(0, limit - (st.used || 0) + e.was) }); }); }, removePrompt: function () { var self = this; if (!self.edit.id || !window.confirm(t('aiinsights.edit.remove_confirm'))) { return; } $http.post('/rest/v1/aiinsights.prompt_delete', { site_id: h.siteId(), prompt_id: self.edit.id }).then(function () { if (self.status && self.status.prompts) { self.status.prompts--; } self.edit = { show: false }; aiState.cache = {}; if (self.promptId) { $location.path(BASE + '/antwoorden'); } else { self.load(); } }); }, // ------------------------------------------------------------------ pakket (zoals de Rank tracker) // white label-admins zetten het (aiinsights.plan_save), de eigenaar van een directe site koopt het (aiinsights.buy); // de prijs komt van de server settings: null, openPlan: function () { var self = this; $http.post('/rest/v1/aiinsights.plan', { site_id: h.siteId() }).then(function (res) { var d = res.data.data; self.settings = { own: d.own_plan, buy: !!d.buy, now: d.pay_now_eur, price: d.price_eur, freq: Math.max(0, FREQS.indexOf(d.plan.frequency)), answers: Math.max(0, ANSWER_STEPS.filter(function (n) { return n <= d.plan.measurements; }).length - 1) }; }); }, planOf: function () { return { frequency: FREQS[+this.settings.freq], measurements: ANSWER_STEPS[+this.settings.answers] }; }, planChanged: function () { var self = this; if (self._priceTimer) { $timeout.cancel(self._priceTimer); } self._priceTimer = $timeout(function () { $http.post('/rest/v1/aiinsights.plan_price', angular.extend({ site_id: h.siteId() }, self.planOf())).then(function (res) { if (self.settings) { self.settings.price = res.data.data.price_eur; self.settings.now = res.data.data.pay_now_eur; } }); }, 250); }, planSaved: function () { this.settings = null; aiState.cache = {}; this.init(); }, savePlan: function (followDefault) { var self = this; $http.post('/rest/v1/aiinsights.plan_save', { site_id: h.siteId(), plan: followDefault ? null : self.planOf() }).then(function () { self.planSaved(); }); }, // directe klant: naar Mollie als er nu iets te betalen is, anders staat het pakket meteen aan buyPlan: function () { var self = this; self.settings.busy = true; $http.post('/rest/v1/aiinsights.buy', { site_id: h.siteId(), plan: self.planOf() }).then(function (res) { if (res.data.data.checkout_url) { window.location.href = res.data.data.checkout_url; return; } self.planSaved(); }, function () { self.settings.busy = false; }); }, // ------------------------------------------------------------------ één prompt viewPrompt: function (d) { var self = this; var withAnswer = d.engines.filter(function (e) { return e.answer; }); var mentioned = withAnswer.filter(function (e) { return e.answer.status === 'linked' || e.answer.status === 'mentioned'; }); var best = null; withAnswer.forEach(function (e) { if (e.answer.pos && (!best || e.answer.pos < best.pos)) { best = { pos: e.answer.pos, name: e.name }; } }); var visits = d.pages.reduce(function (s, p) { return s + (p.traffic ? p.traffic.sessions : 0); }, 0); var terms = ((self.status && self.status.brand) || []).filter(function (x) { return x.length >= 3; }); // het antwoord als leesbare tekst: eerst escapen, dan de markdown van de AI's (vet, kopjes, opsommingen, // links als alleen de tekst), dan je merknaam markeren var mark = function (text) { var h = esc(text || '') .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1') .replace(/^#{1,6}\s*(.+)$/gm, '$1') .replace(/\*\*([^*\n]+)\*\*/g, '$1') .replace(/^\s*[*-]\s+/gm, '• '); terms.forEach(function (term) { h = h.replace(new RegExp('(^|[^\\p{L}\\p{N}])(' + esc(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')(?![\\p{L}\\p{N}])', 'giu'), '$1$2'); }); return $sce.trustAsHtml(h.replace(/\n/g, '
')); }; return { prompt: d.prompt, kpis: [ { label: t('aiinsights.prompt.kpi_mentioned'), value: t('aiinsights.prompt.n_of_ai', { n: mentioned.length, total: withAnswer.length }), note: mentioned.map(function (e) { return e.name; }).join(', ') }, { label: t('aiinsights.prompt.kpi_best'), value: best ? String(best.pos) : '–', note: best ? best.name : t('aiinsights.prompt.not_linked') }, { label: t('aiinsights.prompt.kpi_visits'), value: d.pages.length ? f.num(visits) : '–', note: d.pages.length ? t('aiinsights.prompt.kpi_visits_note') : t('aiinsights.prompt.no_page') } ], weeks: d.weeks.map(function (w) { return f.day(w); }), grid: d.engines.map(function (e) { return { name: e.name, cells: e.weeks.map(function (c, i) { return { cls: c ? WEEK[c.status] : 'bg-gray-50 border border-gray-200', label: c && c.pos ? c.pos : '', title: f.day(d.weeks[i]) + ': ' + (c ? t('aiinsights.week.' + c.status) : t('aiinsights.week.none_measured')) }; }) }; }), answers: d.engines.map(function (e) { var x = e.answer; return { name: e.name, has: !!x, status: x ? x.status : '', pos: x && x.pos ? x.pos : '', meta: x ? f.day(x.day) : '', html: x ? mark(x.text) : '', sources: x ? x.sources : [], noSources: x && !x.sources.length, brands: x ? x.others.filter(function (o) { return o.mentioned; }).map(function (o) { return o.name; }).join(', ') : '' }; }), pages: d.pages.map(function (p) { return { page: p.page, cited: p.cited.map(function (c) { return self.name(c.engine) + (c.pos ? ' (' + c.pos + ')' : ''); }).join(', '), sessions: p.traffic ? f.num(p.traffic.sessions) : '–', conversions: p.traffic ? f.num(p.traffic.conversions) : '–' }; }) }; }, // ------------------------------------------------------------------ concurrenten viewCompetitors: function (d) { var self = this, total = d.answers; var row = function (o, own) { var eng = o.engines || {}; return { name: o.name, own: own, followed: !!o.followed, appears: own ? o.mentioned : o.answers, share: total ? 100 * (own ? o.mentioned : o.answers) / total : 0, shareText: self.ofText(own ? o.mentioned : o.answers, total), linkedText: self.ofText(o.linked, total), perEngine: ENGINES.map(function (e) { return eng[e] || 0; }).join(' · '), diff: o.before === null || o.before === undefined ? null : (own ? o.mentioned : o.answers) - o.before }; }; var rows = [row(d.own, true)].concat(d.others.map(function (o) { return row(o, false); })); rows.sort(function (x, y) { return y.appears - x.appears || (x.own ? -1 : 1); }); return { total: total, rows: rows, pages: d.pages, followed: d.followed, canWrite: d.can_write }; }, toggleFollow: function (r) { var self = this, list = (self.v.followed || []).slice(); var i = list.indexOf(r.name); if (i > -1) { list.splice(i, 1); } else if (list.length < 6) { list.push(r.name); } else { return; } r.followed = i === -1; self.v.followed = list; $http.post('/rest/competitors/save', { websiteid: h.siteId(), competitors: list }).then(function (res) { self.v.followed = res.data.competitors; if (self.status) { self.status.competitors = res.data.competitors; } }); }, // ------------------------------------------------------------------ AI-verkeer trafficFilter: '', setTrafficFilter: function (e) { this.trafficFilter = this.trafficFilter === e ? '' : e; this.refresh(); }, viewTraffic: function (d) { var self = this; if (!d.ga4) { return { noGa4: true }; } var ch = function (x, y) { return y ? 100 * (x - y) / y : null; }; var n = d.total.now, b = d.total.before; var main = ['chatgpt', 'perplexity', 'gemini']; var flt = self.trafficFilter; // het bezoek van één pagina uit de gekozen AI (of alle AI's); daarop sorteren, de 50 grootste tonen var other = function (p) { return Object.keys(p.engines).filter(function (x) { return main.indexOf(x) === -1; }).reduce(function (t, x) { return t + p.engines[x]; }, 0); }; var from = function (p) { return !flt ? p.sessions : (flt === 'other' ? other(p) : (p.engines[flt] || 0)); }; var pages = d.pages.filter(function (p) { return from(p) > 0; }).sort(function (a, b) { return from(b) - from(a); }).slice(0, 50).map(function (p) { var bar = main.concat(['other']).map(function (e) { var s = e === 'other' ? other(p) : (p.engines[e] || 0); return { color: COLORS[e], pct: p.sessions ? 100 * s / p.sessions : 0, title: (e === 'other' ? t('aiinsights.engines.other') : self.name(e)) + ': ' + f.num(s) }; }); return { page: p.page === '(not set)' ? t('aiinsights.traffic.not_set') : p.page, // met een filter: het bezoek uit die AI (de balk laat de verdeling over alle AI's zien) sessions: f.num(from(p)), delta: flt ? null : ch(p.sessions, p.before), bar: bar, engaged: f.pct(p.sessions ? 100 * p.engaged / p.sessions : null, 0), conversions: f.num(p.conversions), revenue: p.revenue ? f.euro(p.revenue) : '–', cited: p.cited ? self.ofText(p.cited, d.prompts) : '–' }; }); return { kpis: [ { label: t('aiinsights.kpi.visits'), value: f.num(n.sessions), delta: ch(n.sessions, b.sessions) }, { label: t('aiinsights.traffic.share'), value: d.total.all_sessions ? f.pct(100 * n.sessions / d.total.all_sessions) : '–' }, { label: t('aiinsights.traffic.engaged'), value: f.pct(n.sessions ? 100 * n.engaged / n.sessions : null, 0) }, { label: t('aiinsights.kpi.conversions'), value: f.num(n.conversions), delta: ch(n.conversions, b.conversions) }, { label: t('aiinsights.kpi.revenue'), value: n.revenue ? f.euro(n.revenue) : '–', delta: n.revenue ? ch(n.revenue, b.revenue) : null } ], filters: [{ key: '', label: t('aiinsights.traffic.all') }].concat(main.map(function (e) { return { key: e, label: self.name(e) }; })).concat([{ key: 'other', label: t('aiinsights.engines.other') }]), legend: main.concat(['other']).map(function (e) { return { color: COLORS[e], label: e === 'other' ? t('aiinsights.engines.other') : self.name(e) }; }), pages: pages }; } }; // ---------------------------------------------------------------------- het antwoord van de API in de vorm van de pagina // (één plek; de view-functies en sjablonen hierboven bleven gelijk) // aiinsights.summary -> de kop van elke pagina (a.status) function fromApiStatus(s) { return { prompts: s.prompts, last_day: s.last_measured_date, ga4: s.ga4_connected, gsc: s.search_console_connected, serp: s.rank_tracker_keywords > 0, brand: s.brand_terms, competitors: s.competitors, can_write: s.can_write, plan: { freq: s.package.frequency, answers: s.package.measurements }, included: { freq: s.included.frequency, answers: s.included.measurements }, used: s.measurements_used, can_plan: s.can_set_plan, can_buy: s.can_buy }; } // per AI een getal: {openai: 3, google: 1} (onze metingen, de interne namen), of met raw de namen van het bezoek // ({chatgpt: 12, perplexity: 2}) function perEngine(list, field, raw) { var o = {}; (list || []).forEach(function (x) { o[raw ? x.key : (INTERNAL[x.key] || x.key)] = x[field]; }); return o; } // aiinsights.prompts + ai_overviews + summary -> AI-antwoorden function fromApiAnswers(list, aio, s) { return { weeks: list.length ? list[0].weeks.map(function (w) { return w.date; }) : [], prompts: list.map(function (p) { var cells = {}; p.per_engine.forEach(function (r) { cells[INTERNAL[r.key]] = r.asked || r.status ? { status: r.status, pos: r.position, day: r.date } : null; }); return { id: p.id, q: p.prompt, providers: p.engines.map(function (k) { return INTERNAL[k]; }), brands: p.brand_terms, cells: cells, who: p.also_mentioned, weeks: p.weeks.map(function (w) { return w.status; }), errors: p.errors, paused: p.paused }; }), aio: { measured: aio.length, rows: aio.filter(function (r) { return r.ai_overview; }).map(function (r) { return { q: r.keyword, cc: r.country, device: r.device, pos: r.position, who: r.also_cited, organic: r.organic_position }; }) }, plan: { freq: s.package.frequency, answers: s.package.measurements }, used: s.measurements_used, can_write: s.can_write, can_plan: s.can_set_plan, can_buy: s.can_buy }; } // aiinsights.prompt -> één prompt function fromApiPrompt(d) { return { prompt: { id: d.id, q: d.prompt, locale: { cc: d.country, lc: d.language }, created: d.created_at ? d.created_at.slice(0, 10) : null }, weeks: d.per_engine.length ? d.per_engine[0].weeks.map(function (w) { return w.date; }) : [], engines: d.per_engine.map(function (e) { var x = e.answer; return { engine: INTERNAL[e.key], name: e.label, weeks: e.weeks.map(function (w) { return w.status ? { status: w.status, pos: w.position } : null; }), answer: x ? { day: x.date, status: x.status, pos: x.position, text: x.text, sources: x.sources.map(function (o) { return { pos: o.position, url: o.url, domain: o.domain, own: o.own }; }), others: x.other_sites.map(function (o) { return { name: o.domain, mentioned: o.mentioned, pos: o.position }; }) } : null }; }), pages: d.own_pages.map(function (p) { return { page: p.path, cited: p.cited_by.map(function (c) { return { engine: c.key, pos: c.position }; }), traffic: p.ai_sessions === null ? null : { sessions: p.ai_sessions, conversions: p.conversions, revenue: p.revenue_eur } }; }) }; } // aiinsights.competitors + summary -> concurrenten function fromApiCompetitors(c, s) { return { answers: c.answers, own: { name: c.own.domain, answers: c.own.answers, mentioned: c.own.mentioned, linked: c.own.linked, engines: perEngine(c.own.per_engine, 'mentioned'), before: c.own.mentioned_previous }, others: c.sites.map(function (o) { return { name: o.domain, answers: o.answers, mentioned: o.mentioned, linked: o.linked, engines: perEngine(o.per_engine, 'answers'), before: o.answers_previous, followed: o.followed }; }), pages: c.cited_pages.map(function (p) { return { url: p.url, n: p.citations, prompts: p.prompts }; }), followed: c.competitors, can_write: s.can_write }; } // aiinsights.traffic_summary + traffic_pages + summary -> AI-verkeer function fromApiTraffic(tr, pages, s) { if (!tr.ga4_connected) { return { ga4: false }; } var at = function (k) { return { sessions: tr.sessions[k], engaged: tr.engaged_sessions[k], conversions: tr.conversions[k], revenue: tr.revenue_eur[k] }; }; return { ga4: true, total: { now: at('value'), before: at('previous'), all_sessions: tr.all_sessions }, prompts: s.prompts, pages: pages.map(function (p) { return { page: p.path, sessions: p.sessions, engaged: p.engaged_sessions, conversions: p.conversions, revenue: p.revenue_eur, engines: perEngine(p.per_engine, 'sessions', true), before: p.sessions_previous, cited: p.cited_by_prompts }; }) }; } // terug van Mollie: de webhook verwerkt de betaling, meestal binnen een paar seconden. Tot 30 seconden de status // van de bestelling opvragen (API v1 serp.order: de bestellingen van beide add-ons) en zeggen wat er echt gebeurde. if (!report && $location.search().betaald) { var orderId = $location.search().betaald, tries = 0; $location.search('betaald', null); a.paidMessage = 'serp.paid_wait'; var check = function () { $http.post('/rest/v1/serp.order', { site_id: h.siteId(), order_id: orderId }).then(function (res) { res = res.data; var st = res.data.status; if (st === 'paid') { a.paidMessage = 'aiinsights.plan.paid_ok'; aiState.cache = {}; a.init(); } else if (st && st !== 'open') { a.paidMessage = 'aiinsights.plan.paid_failed'; } else if (++tries < 15) { return $timeout(check, 2000); } $timeout(function () { a.paidMessage = null; }, 8000); }); }; check(); } $translate.onReady().then(function () { a.init(); }); }]); app.controller('contentcheck', ['$scope', '$http', '$timeout', '$routeParams', '$location', 'h', 'mtFmt', function ($scope, $http, $timeout, $routeParams, $location, h, mtFmt) { // Contentcheck (2026-10-27). Verstopt: alleen eigen admins. Teksten nog vast in het Nederlands; vertalingen bij de // lancering. Arjen: niets over hoe het werkt op de pagina of in deze code (die is openbaar). var BASE = '/content-intelligence/check'; var f = mtFmt; // factoren: naam, wat goed gaat, wat je kunt doen, hoe de waarde te tonen (null: alleen de balk, geen waarde) var FACTORS = { query: ['Aansluiting op de zoekvraag', 'Je tekst sluit aan op waar mensen echt naar zoeken.', 'Beantwoord de zoekvraag vollediger en eerder in je artikel.', null], like_top: ['Lijkt op de winnaars', 'Inhoudelijk sta je naast de artikelen die winnen.', 'Met je actieplan schuif je inhoudelijk naar de winnaars toe.', null], novelty: ['Eigen zijpaden', 'Je blijft dicht bij wat lezers bij deze zoekvraag verwachten.', 'Je dwaalt af van wat lezers verwachten. Kort de zijpaden in.', null], coherence: ['Samenhang', 'Je blijft goed bij het onderwerp.', 'Haal stukken weg die niet over het onderwerp gaan.', null], answer_first: ['Direct antwoord bovenaan', 'Je geeft meteen antwoord. Google en lezers belonen dat.', 'Begin met het antwoord in twee zinnen. Google en lezers belonen dat.', null], question_heads: ['Vraagkoppen', 'Je koppen sluiten aan op de vragen die lezers stellen.', 'Maak een paar koppen een vraag, zoals lezers hem stellen.', function (v) { return Math.round(100 * v) + '%'; }], address: ['Aanspreekvorm', 'Je spreekt de lezer persoonlijk aan. Sterk.', 'Spreek de lezer vaker direct aan met je en jouw.', function (v) { return Math.round(100 * v) + '%'; }], age: ['Actualiteit', 'Je informatie is vers. Dat is een voorsprong.', 'Werk je artikel bij en laat de datum zien.', function (v) { return Math.round(v) + ' dagen'; }], words: ['Lengte', 'Je artikel heeft de lengte die bij deze zoekvraag past.', 'Je lengte past niet bij deze zoekvraag; zie je richting.', function (v) { return f.num(Math.round(v)) + ' woorden'; }], vocab: ['Woorden die lezers verwachten', 'Je gebruikt de woorden die bij deze vraag horen.', 'Gebruik vaker de woorden die lezers bij deze vraag verwachten.', null], ads: ['Advertenties', 'Weinig advertenties in en rond je tekst.', 'Meer advertenties dan bij de winnaars.', null], affiliate: ['Affiliate-links', 'Weinig of geen affiliate-links.', 'Meer affiliate-links dan bij de winnaars.', null] }; // wat we onderzochten: vijf pijlers, met de factoren die erbij horen (onderwerpen: de gemiste onderwerpen) var PILLARS = [ ['topics', 'Onderwerpen', 'Elk onderwerp in het zoeklandschap, en of jij het behandelt.', [], 'M4 6h16M4 12h10M4 18h7'], ['intent', 'Zoekintentie', 'Hoe precies je antwoord geeft op waar mensen echt naar zoeken.', ['query', 'answer_first', 'vocab'], 'M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14zM21 21l-4.3-4.3'], ['structure', 'Structuur', 'Koppen, opbouw en hoe snel de lezer bij het antwoord is.', ['question_heads', 'words'], 'M4 5h16v4H4zM4 13h7v6H4zM15 13h5v6h-5z'], ['tone', 'Toon en stijl', 'Hoe je de lezer aanspreekt en bij het onderwerp blijft.', ['address', 'coherence'], 'M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z'], ['fresh', 'Actualiteit', 'Hoe vers je informatie is ten opzichte van de winnaars.', ['age'], 'M12 7v5l3 2M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18z'], ['commerce', 'Commercie', 'Advertenties en affiliate-links in en rond je artikel.', ['ads', 'affiliate'], 'M3 3h2l2.4 12h11.2L21 7H6.2M9 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM18 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2z'] ]; var VERDICT = { ok: 'Sterk', part: 'Bijna', miss: 'Aandacht' }; // het wachtscherm: vijf stappen over de voortgang (pct) van de analyse var STEPS = [ ['Zoeklandschap in kaart', 'Welke artikelen Google voor deze zoekvraag beloont.', 'Winnende artikelen voor deze zoekvraag gevonden'], ['Winnaars ontleed', 'Elk winnend artikel gelezen, tot op de zin.', 'Je eigen artikel gelezen en opgedeeld in onderwerpen'], ['Onderwerpen vergelijken', 'Wat zij behandelen, wat jij behandelt, en wat ertussen zit.', 'Onderwerpenlandschap van de winnaars opgebouwd'], ['Signalen meten', 'Zoekintentie, structuur, toon en actualiteit.', 'Je artikel gemeten op elk signaal dat telt'], ['Je actieplan', 'Concrete koppen, op de juiste plek in je artikel.', 'Je actieplan geschreven'] ]; var ERRORS = { no_serp: 'De zoekresultaten voor dit zoekwoord konden we niet ophalen. Probeer het later nog eens.', too_few_articles: 'Te weinig vergelijkbare artikelen van concurrenten. Voor dit zoekwoord toont Google vooral andere soorten pagina\'s.', own_unreadable: 'Jouw pagina was niet te lezen.', bad_url: 'De pagina moet van deze site zijn.', seoserver: 'De check kon niet starten. Probeer het zo nog eens.', timeout: 'De analyse duurde te lang en is gestopt.', crash: 'De analyse liep vast.' }; var DONE_KEY = 'contentcheck.done.'; var c = $scope.cc = { id: $routeParams.id || null, f: f, q: $location.search().q || '', url: $location.search().url || '', filter: '', loading: true, init: function () { var self = this; if (self.id) { self.poll(); return; } $http.get('/rest/contentcheck/options', { params: { websiteid: h.siteId() } }).then(function (res) { self.loading = false; self.options = res.data.options; self.gsc = res.data.gsc; self.checks = res.data.checks; }, function (res) { self.loading = false; self.denied = res.status === 403; }); }, filtered: function () { var t = this.filter.toLowerCase(); return (this.options || []).filter(function (o) { return !t || o.q.indexOf(t) > -1 || o.url.toLowerCase().indexOf(t) > -1; }).slice(0, 60); }, path: function (u) { return String(u || '').replace(/^https?:\/\/[^\/]+/, '') || '/'; }, pick: function (o) { this.q = o.q; this.url = o.url; this.start(); }, start: function () { var self = this; self.busy = true; self.error = ''; $http.post('/rest/contentcheck/start', { websiteid: h.siteId(), q: self.q, url: self.url }).then(function (res) { $location.path(BASE + '/' + res.data.id).search({}); }, function (res) { self.busy = false; self.error = ERRORS[(res.data && res.data.error) || 'seoserver'] || ERRORS.seoserver; }); }, // ---------------------------------------------------------------- één check poll: function () { var self = this; $http.get('/rest/contentcheck/get', { params: { id: self.id } }).then(function (res) { self.loading = false; self.check = res.data; if (res.data.state === 'running') { self.steps = self.progress(res.data.pct || 0); self._t = $timeout(function () { self.poll(); }, 3000); } else if (res.data.state === 'done') { self.v = self.view(res.data.result); } else { self.errorText = ERRORS[(res.data.result && res.data.result.error) || 'crash'] || ERRORS.crash; self.v = null; } }, function (res) { self.loading = false; self.denied = res.status === 403; }); }, progress: function (pct) { var now = Math.min(STEPS.length - 1, Math.floor(pct / (100 / STEPS.length))); return STEPS.map(function (x, i) { return { n: i + 1, name: x[0], text: x[1], feed: x[2], state: i < now ? 'done' : (i === now ? 'now' : 'next') }; }); }, view: function (r) { var self = this, missing = r.missing || [], topics = r.topics || [], extra = r.extra || []; var count = function (list, me) { return list.filter(function (t) { return t.me === me; }).length; }; var factors = (r.factors || []).filter(function (x) { return FACTORS[x.key]; }).map(function (x) { var d = x.pct - x.top_pct, F = FACTORS[x.key], fmt = F[3], state = d >= -10 ? 'ok' : (d >= -25 ? 'part' : 'miss'); var meText = fmt && x.me !== undefined && x.me !== null ? fmt(x.me) : null, topText = fmt && x.top !== undefined && x.top !== null ? fmt(x.top) : null; return angular.extend(x, { name: F[0], note: state === 'ok' ? F[1] : F[2], state: state, values: meText ? 'jij ' + meText + ' · winnaars ' + topText : (state === 'ok' ? 'op of boven het niveau van de winnaars' : 'onder het niveau van de winnaars') }); }); var byKey = {}; factors.forEach(function (x) { byKey[x.key] = x; }); var weak = function (k) { return byKey[k] && byKey[k].state !== 'ok'; }; var nee = count(missing, 'nee'), half = count(missing, 'half'); var pillars = PILLARS.map(function (p) { var state; if (p[0] === 'topics') { state = nee ? 'miss' : (half ? 'part' : 'ok'); } else { var own = p[3].filter(function (k) { return byKey[k]; }); if (!own.length) { return null; } state = own.some(function (k) { return byKey[k].state === 'miss'; }) ? 'miss' : (own.some(function (k) { return byKey[k].state === 'part'; }) ? 'part' : 'ok'); } return { key: p[0], name: p[1], text: p[2], icon: p[4], state: state, verdict: p[0] === 'topics' && nee ? nee + ' gemist' : VERDICT[state] }; }).filter(Boolean); // het actieplan: de gemiste onderwerpen, de grootste winst eerst var actions = missing.map(function (m, i) { return angular.extend({}, m, { n: i + 1, heading: (m.ai && m.ai.heading) || m.name, where: (m.ai && m.ai.where) || '', why: (m.ai && m.ai.why) || '', impact: i === 0 ? 'Hoogste impact' : (m.strength === 'almost_all' || m.strength === 'most' ? 'Grote kans' : 'Snelle winst') }); }); // de checklist volgt je richting: eerst de basis, dan fijnslijpen (Arjen 2026-10-29: allebei meteen) var dir = r.direction || { basis: [], fine: [] }; var stepName = function (s) { return s.kind === 'topic' ? s.heading : s.title; }; var todo = dir.basis.map(function (s, i) { return { key: 'b' + i, name: stepName(s), gain: 'Stap 1 · basis' }; }) .concat(dir.fine.map(function (s, i) { return { key: 'f' + i, name: stepName(s), gain: 'Stap 2 · fijnslijpen' }; })); if (extra.length) { todo.push({ key: 'extra', name: extra.length > 1 ? 'Zijpaden inkorten' : extra[0].name + ' inkorten', gain: 'Scherper artikel' }); } var done = {}; try { done = JSON.parse(window.localStorage.getItem(DONE_KEY + self.id)) || {}; } catch (e) { done = {}; } todo.forEach(function (t) { t.done = !!done[t.key]; }); var score = r.score || { now: null, plan: null }; var biggest = actions.slice(0, 3).map(function (a) { return a.name; }); return { r: r, missing: missing, topics: topics, extra: extra, factors: factors, pillars: pillars, actions: actions, todo: todo, score: score, dir: dir, diagnosis: dir.diagnosis, diagnosisText: dir.diagnosis_text, sources: r.sources || [], nee: nee, half: half, landscape: { covered: topics.length ? Math.round(100 * count(topics, 'ja') / topics.length) : 100, half: count(topics, 'half'), nee: count(topics, 'nee') }, fresh: weak('age') ? null : byKey.age, personal: byKey.address && byKey.address.state === 'ok', gain: score.plan !== null ? score.plan - score.now : 0, biggest: r.summary || (biggest.length ? 'Voeg informatie toe over ' + self.list(biggest.map(function (b) { return '“' + b + '”'; })) + '.' : ''), // de briefing voor je eigen AI-schrijver (research-en-richtlijnen.md), gemaakt op de server prompt: r.briefing || '' }; }, extraName: function (e) { return e.name; }, list: function (a) { return a.length > 1 ? a.slice(0, -1).join(', ') + ' en ' + a[a.length - 1] : a.join(''); }, copy: function () { var self = this, text = self.v.prompt; var done = function () { self.copied = true; $timeout(function () { self.copied = false; }, 2000); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { $scope.$apply(done); }); return; } var ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); done(); }, // de briefing als bestand, voor een AI die bestanden leest (Claude Code, Cursor, een project in ChatGPT) download: function () { var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([this.v.prompt], { type: 'text/markdown;charset=utf-8' })); a.download = 'research-en-richtlijnen.md'; document.body.appendChild(a); a.click(); document.body.removeChild(a); $timeout(function () { URL.revokeObjectURL(a.href); }, 1000); }, // de checklist onthouden per check, in deze browser tick: function () { var done = {}; this.v.todo.forEach(function (t) { if (t.done) { done[t.key] = true; } }); try { window.localStorage.setItem(DONE_KEY + this.id, JSON.stringify(done)); } catch (e) { /* geen opslag: niet erg */ } }, doneCount: function () { return (this.v ? this.v.todo : []).filter(function (t) { return t.done; }).length; }, recheck: function () { this.q = this.check.q; this.url = this.check.url; this.start(); }, strengthLabel: function (s) { return { almost_all: 'Bijna alle winnaars behandelen dit', most: 'De meeste winnaars behandelen dit', half: 'Ongeveer de helft van de winnaars behandelt dit', some: 'Een deel van de winnaars behandelt dit' }[s]; }, meLabel: function (m) { return { ja: 'behandel je', half: 'lijkt te kort', nee: 'lijkt te ontbreken' }[m]; } }; $scope.$on('$destroy', function () { if (c._t) { $timeout.cancel(c._t); } }); c.init(); }]); // Core Web Vitals (CrUX): /crux (Overzicht), /crux/bezoekers (Snelheid en bezoekers), /crux/diagnose (Diagnose). // Echte Chrome-gebruikers uit het Chrome UX Report, op site-niveau: jij naast je concurrenten (uit de Rank tracker), // per week, mobiel / desktop / alle apparaten. Server: API v1 crux.view en crux.set (rest/include/crux/api.php), regels in rest/include/crux/rules.php (ook alle // zinnen: die komen als soort + getallen, hier alleen vertaald). Ontwerp: docs/superpowers/specs/2026-10-28-crux-design.md // (v2). Bouwblokken: js/ui.js (docs/ui.md). Gelanceerd 2026-10-29: user.site.show.crux (standaard aan). // wat bewaard blijft tussen de tabs: de laatst opgehaalde delen (meteen tonen, op de achtergrond vernieuwen) var cruxState = { site: null, cache: {} }; app.controller('crux', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', 'mtCwv', 'mtChart', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt, mtCwv, mtChart) { if (!$rootScope.user || !$rootScope.user.site || !$rootScope.user.site.show || !$rootScope.user.site.show.crux) { $location.path('/'); return; } // /crux/lcp, /crux/inp en /crux/cls: de pagina van één metric (Arjen, 2026-10-29: een aparte pagina, geen paneel) var TABS = { '': 'overview', bezoekers: 'visitors', diagnose: 'diagnose', lcp: 'metric', inp: 'metric', cls: 'metric' }; var PART = { overview: 'overview', visitors: 'visitors', diagnose: 'diagnose', metric: 'metric' }; var BASE = '/crux'; var FF_KEY = 'mt.crux.ff'; var COREDASH_KEY = 'mt.crux.coredash.hidden'; var FFS = ['PHONE', 'DESKTOP', 'ALL']; var CWV = ['lcp', 'inp', 'cls']; // vaste kleur per concurrent (zijn plek in de lijst van de Rank tracker, nooit zijn rang); jij altijd blauw. // Gecontroleerd met de dataviz-validator (kleurenblind-veilig naast elkaar); de tekst staat ernaast (legenda, eind). var COMP_COLORS = ['#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#6250d6']; // de vier LCP-stappen (zoals het ontwerp) var PARTS = ['ttfb', 'delay', 'duration', 'render']; var PART_COLORS = { ttfb: '#0f766e', delay: '#7c3aed', duration: '#d97706', render: '#64748b' }; // navigatietypes: kleur en tekstkleur in het blokje var NAV = [['navigate', '#cbd5e1', '#1f2937'], ['navigate_cache', '#93c5fd', '#1e3a8a'], ['back_forward_cache', '#0f766e', '#ffffff'], ['prerender', '#7c3aed', '#ffffff'], ['back_forward', '#f59e0b', '#1f2937'], ['reload', '#94a3b8', '#1f2937'], ['restore', '#e5e7eb', '#1f2937']]; // bezoekersmetrics, allemaal per sessie (Arjen, 2026-10-29: geen klikken of sessies, die volgen seizoen en SEO); // opbrengst alleen als de site opbrengst meet var VISIT = ['rps', 'conv', 'eng', 'bounce', 'pps']; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (cruxState.site !== h.siteId()) { cruxState = { site: h.siteId(), cache: {} }; } var ff = 'PHONE'; try { ff = window.localStorage.getItem(FF_KEY) || 'PHONE'; } catch (e) { ff = 'PHONE'; } if (FFS.indexOf(ff) < 0) { ff = 'PHONE'; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; // een grens kort: 2,5 s, 200 ms, 0,1 var limitText = function (m, v) { if (m === 'lcp' || m === 'fcp') { return f.num(v / 1000, v % 1000 ? 1 : 0) + ' s'; } if (m === 'cls') { return f.num(v, (String(v).split('.')[1] || '').length); } return f.num(v) + ' ms'; }; var sec1 = function (v) { return f.num(v / 1000, 1) + ' s'; }; var c = $scope.c = { f: f, cwv: mtCwv, tab: TABS[$routeParams.tab || ''] || 'overview', ff: ff, ffs: FFS, tabs: [], d: {}, v: {}, error: null, saving: false, mode: 'group', speed: 'lcp', // standaard pagina's per sessie (Arjen, 2026-10-29) visit: 'pps', visitPicked: false, metric: ['lcp', 'inp', 'cls'].indexOf($routeParams.tab) > -1 ? $routeParams.tab : null, cwvMetrics: CWV, visitMetrics: VISIT.filter(function (m) { return m !== 'rps'; }), coredash: 'https://www.coredash.app/?utm_source=marketingtracer&utm_medium=app&utm_campaign=crux', coredashHidden: (function () { try { return window.localStorage.getItem(COREDASH_KEY) === '1'; } catch (e) { return false; } })(), init: function () { this.tabs = [ { key: 'overview', label: t('crux.tab.overview'), href: BASE }, // de drie metrics als eigen tab (Arjen, 2026-10-29); hun naam is overal dezelfde { key: 'lcp', label: 'LCP', href: BASE + '/lcp', metric: true }, { key: 'inp', label: 'INP', href: BASE + '/inp', metric: true }, { key: 'cls', label: 'CLS', href: BASE + '/cls', metric: true }, { key: 'visitors', label: t('crux.tab.visitors'), href: BASE + '/bezoekers' }, { key: 'diagnose', label: t('crux.tab.diagnose'), href: BASE + '/diagnose' } ]; this.load(); }, // POST /rest/v1/crux.view met de tab (de vorm die de pagina toont); het vorige antwoord voor hetzelfde adres staat er meteen. Snelheid en bezoekers // in twee delen tegelijk (Arjen, 2026-10-29): de snelheid uit onze database staat er meteen, GA4 komt binnen // zodra Google antwoordt (d.head = speed, d.src.ga4) load: function () { var self = this, what = PART[self.tab], ff = self.ff; var parts = self.tab === 'visitors' ? ['speed', 'ga4'] : [null]; self.error = null; self.d = { src: {} }; var put = function (part, data) { if (part === 'ga4') { self.d.src[part] = data; } else { self.d.head = data; } }; parts.forEach(function (part) { var key = what + '|' + ff + (part ? '|' + part : '') + (self.metric ? '|' + self.metric : ''); if (cruxState.cache[key]) { put(part, cruxState.cache[key]); } var body = { site_id: h.siteId(), tab: what, ff: ff }; if (part) { body.part = part; } if (self.metric) { body.m = self.metric; } $http.post('/rest/v1/crux.view', body).then(function (res) { var data = res.data.data; cruxState.cache[key] = data; if (what === PART[self.tab] && ff === self.ff) { put(part, data); self.refresh(); } }, function (res) { if (part === 'ga4') { // GA4 kapot: alleen de bezoekersgrafiek toont de fout if (what === PART[self.tab] && ff === self.ff) { put(part, { failed: true }); self.refresh(); } return; } self.error = res.status === 403 ? 'no_access' : 'server'; }); }); self.refresh(); }, setFf: function (v) { if (v === this.ff) { return; } this.ff = v; try { window.localStorage.setItem(FF_KEY, v); } catch (e) { /* privévenster */ } this.load(); }, hideCoredash: function () { this.coredashHidden = true; try { window.localStorage.setItem(COREDASH_KEY, '1'); } catch (e) { /* geen opslag: niet erg */ } }, setEnabled: function (on) { var self = this; self.saving = true; $http.post('/rest/v1/crux.set', { site_id: h.siteId(), enabled: on }).then(function () { self.saving = false; cruxState.cache = {}; self.load(); }, function () { self.saving = false; self.error = 'server'; }); }, // een voorgestelde concurrent toevoegen aan de concurrenten van de site (de hele lijst), dan meteen zijn CrUX-gegevens halen addCompetitor: function (domain) { var self = this, list = (self.d.head.competitors || []).concat([domain]); self.saving = true; $http.post('/rest/competitors/save', { websiteid: h.siteId(), competitors: list }).then(function () { self.setEnabled(true); }, function () { self.saving = false; self.error = 'server'; }); }, setMode: function (m) { this.mode = m; }, setSpeed: function (m) { this.speed = m; this.refresh(); }, setVisit: function (m) { this.visit = m; this.visitPicked = true; this.refresh(); }, // ------------------------------------------------------------------ alles wat de pagina toont, één keer per nieuwe data refresh: function () { var self = this, g = self.d.head; self.v = { status: self.statusLine(g) }; if (!g || !g.enabled) { return; } if (self.tab === 'overview' && g.rows) { self.v.kpis = self.viewKpis(g); self.v.notable = g.notable ? self.viewNotable(g.notable) : null; self.v.board = self.viewBoard(g); self.v.trend = self.viewTrend(g); } if (self.tab === 'visitors' && g.speed) { var ga = self.d.src.ga4; self.visitMetrics = VISIT.filter(function (m) { return m !== 'rps' || (ga && ga.has_revenue); }); // opbrengst eerst als de site die meet (tot je zelf iets kiest) if (self.visitMetrics.indexOf(self.visit) < 0) { self.visit = 'bounce'; } self.v.vis = self.viewVisitors(g, self.d.src); } if (self.tab === 'diagnose' && g.rows) { self.v.diag = self.viewDiagnose(g); } if (self.tab === 'metric' && g.weeks) { self.v.deep = self.viewDeep(g, self.metric, g); } }, statusLine: function (g) { if (!g) { return ''; } if (!g.enabled) { return t('crux.status_off'); } if (!g.newest) { return t('crux.status_pending'); } if (this.tab === 'visitors') { return t('crux.status_visitors', { device: t('crux.ffl.' + this.ff) }); } return t('crux.status_line', { date: f.day(g.newest) }); }, compColor: function (r) { return r.own ? '#2563eb' : COMP_COLORS[(r.color >= 0 ? r.color : 0) % COMP_COLORS.length]; }, weekTitle: function (e) { return t('crux.week', { date: f.day(e) }); }, nodataText: function () { return this.ff === 'ALL' ? t('crux.nodata') : t('crux.nodata_all'); }, // de eerste week van elke maand, met minstens twee weken tussen twee labels months: function (weeks) { var out = [], last = null, lastI = -9; weeks.forEach(function (e, i) { var mo = e.slice(0, 7); if (mo !== last) { last = mo; if (i - lastI >= 3) { out.push({ i: i, label: f.month(e) }); lastI = i; } } }); return out; }, // ------------------------------------------------------------------ Overzicht viewKpis: function (g) { var own = g.rows[0], out = [], self = this; if (!own || own.pending) { return ['status', 'lcp', 'inp', 'cls', 'place'].map(function (k) { return { label: t('crux.kpi.' + k), loading: true }; }); } var bad = CWV.filter(function (m) { var r = mtCwv.rating(m, own.p75[m]); return r && r !== 'good'; }); out.push({ label: t('crux.kpi.status'), value: own.status ? t('crux.kpi.' + own.status) : t('crux.kpi.nodata'), mark: own.status === 'pass' ? 'good' : (own.status === 'fail' ? 'poor' : 'none'), note: own.status === 'pass' ? t(own.partial ? 'crux.kpi.pass_partial' : 'crux.kpi.pass_note') : (own.status === 'fail' ? t('crux.kpi.fail_note', { metrics: bad.map(function (m) { return t('crux.metric.' + m); }).join(', ') }) : self.nodataText()) }); CWV.forEach(function (m) { var l = own.last[m], lim = ' · ' + t('crux.kpi.good_to', { v: limitText(m, mtCwv.LIMITS[m][0]) }); if (!l) { out.push({ key: m, href: BASE + '/' + m, label: t('crux.kpi.' + m), value: '–', note: self.nodataText() }); return; } var delta = l.stale ? null : own.pct[m]; out.push({ key: m, href: BASE + '/' + m, label: t('crux.kpi.' + m), value: mtCwv.fmt(m, l.v), delta: delta, note: (l.stale ? t('crux.kpi.stale', { date: f.day(l.end) }) : (delta !== null ? t('crux.kpi.vs4w') : t('crux.kpi.p75'))) + lim }); }); if (g.place) { var lcps = g.rows.filter(function (r) { return r.status && r.p75.lcp !== null; }).map(function (r) { return r.p75.lcp; }); var fastest = own.p75.lcp !== null && own.p75.lcp <= Math.min.apply(null, lcps); out.push({ label: t('crux.kpi.place'), value: '#' + g.place.pos, suffix: t('crux.kpi.of', { of: g.place.of }), note: g.place.of > 1 && fastest ? t('crux.kpi.fastest', { n: g.place.of }) : t('crux.kpi.place_note') }); } else { out.push({ label: t('crux.kpi.place'), value: '–', note: g.competitors.length ? self.nodataText() : t('crux.kpi.place_none') }); } return out; }, // "Opvallend": een vette zin en de rest (crux::notable) viewNotable: function (n) { var head = n.own ? t('crux.note.own_' + n.dir, { pct: n.pct }) : t('crux.note.' + n.dir, { domain: n.domain, pct: n.pct }); var rest = t('crux.note.lcp', { from: f.num(n.from / 1000, 2), to: mtCwv.fmt('lcp', n.to) }); if (n.cls) { var better = n.cls[1] < n.cls[0]; rest += ' ' + t(better ? 'crux.note.cls_better' : 'crux.note.cls_worse', { from: mtCwv.fmt('cls', n.cls[0]), to: mtCwv.fmt('cls', n.cls[1]) }); } rest += '.'; if (n.lead) { rest += ' ' + t('crux.note.' + n.lead[0], { from: sec1(n.lead[1]), to: sec1(n.lead[2]) }); } return { head: head, rest: rest }; }, // het scorebord: op plek (geslaagd, aandeel goed, snelste LCP), domeinen zonder data onderaan viewBoard: function (g) { var self = this; var rows = g.rows.slice().sort(function (a, b) { return (a.rank || 99) - (b.rank || 99); }); var first = rows[0] && rows[0].rank === 1 ? rows[0] : null; return rows.map(function (r) { var stale = r.last.lcp && r.last.lcp.stale; var gap = r.own && first && first !== r && r.p75.lcp !== null && first.p75.lcp !== null ? r.p75.lcp - first.p75.lcp : null; return { domain: r.domain, own: r.own, rank: r.rank || '–', pending: r.pending, nodata: !r.pending && !r.status, badge: r.status ? 'cwv_' + r.status : 'cwv_nodata', partial: r.partial, cells: CWV.map(function (m) { var l = r.last[m]; return { m: m, value: l ? l.v : null, note: l && l.stale ? t('crux.board.last_measured') : '', title: l && l.stale ? self.weekTitle(l.end) : '' }; }), change: stale ? null : r.pct.lcp, was: !stale && r.prev.lcp !== null && r.pct.lcp !== null ? t('crux.board.was', { v: mtCwv.fmt('lcp', r.prev.lcp) }) : '', behind: gap === null ? '' : (gap > 0 ? t('crux.board.behind', { v: mtCwv.fmt('lcp', gap), domain: first.domain }) : t('crux.board.behind_other', { domain: first.domain })) }; }); }, // de trend: drie kleine grafieken (LCP, INP, CLS) naast elkaar () viewTrend: function (g) { var self = this, weeks = g.weeks || [], n = weeks.length; if (!n) { return { empty: true }; } var own = g.rows[0], comps = g.rows.filter(function (r) { return !r.own && r.hist; }); var panels = CWV.map(function (m) { var lim = mtCwv.LIMITS[m], all = []; var push = function (vals) { (vals || []).forEach(function (v) { if (!none(v)) { all.push(+v); } }); }; if (own.hist) { push(own.hist[m]); } comps.forEach(function (r) { push(r.hist[m]); }); var top = Math.min(Math.max(Math.max.apply(null, all.concat([0])), lim[0] * 1.2), lim[1] * 2); var max = mtChart.niceMax(top), step = mtChart.niceMax(max / 3), ticks = []; for (var v = 0; v <= max + 1e-9; v += step) { ticks.push(+v.toFixed(4)); } var l = own.last[m]; return { title: t('crux.kpi.' + m), sub: l ? t('crux.trend.now', { v: mtCwv.fmt(m, l.v) }) + (l.stale ? ' (' + self.weekTitle(l.end) + ')' : '') : self.nodataText(), fmt: mtCwv.fmtOf(m), tick: function (x) { return limitText(m, x); }, max: max, ticks: ticks, good: lim[0], poor: lim[1], limitLabel: t('crux.kpi.good_to', { v: limitText(m, lim[0]) }), own: own.hist ? own.hist[m] : [], band: g.groups[m], med: g.groups[m].med, others: comps.map(function (r) { return { label: r.domain, color: self.compColor(r), values: r.hist[m] }; }), takeaway: self.takeText(m, g.takeaways[m] || []) }; }); return { n: n, weeks: n, data: { n: n, titles: weeks.map(function (e) { return self.weekTitle(e); }), months: self.months(weeks), labels: { own: t('crux.trend.you'), med: t('crux.trend.median'), nodata: t('crux.trend.too_little') }, others: comps.map(function (r) { return { label: r.domain, color: self.compColor(r) }; }), panels: panels }, hasComps: comps.length > 0 }; }, // de zinnen onder een grafiek (crux::takeaways) takeText: function (m, list) { var metric = t('crux.metric.' + m); return list.map(function (x) { switch (x.kind) { case 'always_best': case 'best_now': case 'worst_now': case 'all_good': return t('crux.take.' + x.kind + '.' + m); case 'middle': return t('crux.take.middle', { pos: x.pos, of: x.of }); case 'recovered': return t('crux.take.recovered', { domain: x.domain, zone: t('crux.take.zone.' + x.zone) }); case 'own_change': return t('crux.take.own_change.' + x.dir, { m: metric, pct: x.pct }); default: return t('crux.take.' + x.kind, { domain: x.domain, m: metric }); } }).join(' '); }, // ------------------------------------------------------------------ Snelheid en bezoekers // src.ga4 zodra binnen; tot dan is de bezoekersgrafiek een glimmer. Daaronder: de samenhang (losse periodes van 28 // dagen, verandering tegen verandering), de grootste sprong in snelheid en het bezoek in dezelfde periodes viewVisitors: function (g, src) { var self = this, n = (g.weeks || []).length, sm = self.speed, vm = self.visit; if (!n) { return { empty: true }; } var part = (src || {}).ga4; var vis = part && part.failed ? { state: 'error' } : (part && part.visit && part.visit[vm]) || { state: 'loading' }; var cmpAll = (part && part.compare) || {}; var sv = g.speed[sm], lim = mtCwv.LIMITS[sm]; var smax = mtChart.niceMax(Math.max.apply(null, sv.filter(function (x) { return !none(x); }).concat([lim[0] * 0.2])) * 1.05); var vfmt = self.visitFmt(vm); var vmax = vis.state === 'ok' ? mtChart.niceMax(Math.max.apply(null, vis.values.filter(function (x) { return !none(x); }).concat([vm === 'bounce' || vm === 'conv' ? 1 : 0.01]))) : 1; var data = { n: n, titles: g.weeks.map(function (e) { return self.weekTitle(e); }), months: self.months(g.weeks), top: { title: t('crux.vis.speed_week', { m: t('crux.metric.' + sm) }), sub: t(sm === 'cls' ? 'crux.vis.lower_stable' : 'crux.vis.lower_faster'), label: t('crux.metric.' + sm), values: sv, fmt: mtCwv.fmtOf(sm), tick: function (x) { return limitText(sm, x); }, max: smax, good: lim[0], poor: lim[1] }, bottom: { title: t('crux.vis.week.' + vm), sub: t('crux.vis.src_ga4', { device: t('crux.ffl.' + self.ff) }) + ' · ' + t('crux.vis.better.' + (vm === 'bounce' ? 'lower' : 'higher')), label: t('crux.vis.btn.' + vm), values: vis.state === 'ok' ? vis.values : null, fmt: vfmt, tick: vfmt, max: vmax }, // één grafiek: de snelheid als lijn, het bezoek als staven erachter (mt-pair overlay) overlay: { title: t('crux.vis.overlay_title', { m: t('crux.metric.' + sm), v: t('crux.vis.btn.' + vm) }), left: t('crux.vis.axis_left'), right: t('crux.vis.axis_right') } }; var cmp = cmpAll[sm] ? cmpAll[sm][vm] : null, tiles = null; var metric = t('crux.metric.' + sm), short = t('crux.vis.short.' + vm), sfmt = mtCwv.fmtOf(sm); if (vis.state === 'loading') { tiles = [t('crux.vis.tile_rel'), t('crux.vis.tile_jump.faster', { m: metric }), t('crux.vis.tile_after', { v: t('crux.vis.btn.' + vm) })] .map(function (l) { return { label: l, loading: true }; }); } else if (vis.state === 'ok' && cmp) { var r = cmp.relation, j = cmp.jump, relNote; if (r.strength === 'too_few') { relNote = t('crux.vis.rel_too_few', { n: r.pairs + 1 }); } else if (!r.moved) { relNote = t('crux.vis.rel_still', { m: metric }); } else if (r.dir === 'opposite' && r.strength !== 'none') { relNote = t('crux.vis.rel_opposite', { v: short }); } else { relNote = t('crux.vis.rel_with', { with: r.with, moved: r.moved, m: metric, v: short }); } tiles = [{ label: t('crux.vis.tile_rel'), value: t('crux.vis.rel.' + (r.strength === 'none' && !r.moved ? 'none' : r.strength)), note: relNote, mark: r.strength === 'clear' && r.dir === 'expected' ? 'good' : '' }]; if (j) { tiles.push({ label: t('crux.vis.tile_jump.' + j.dir, { m: metric }), value: sfmt(j.before.speed) + ' → ' + sfmt(j.after.speed), note: t('crux.vis.jump_note', { from: f.day(j.before.end), to: f.day(j.after.end) }) }); tiles.push({ label: t('crux.vis.tile_after', { v: t('crux.vis.btn.' + vm) }), value: vfmt(j.before.visit) + ' → ' + vfmt(j.after.visit), note: t('crux.vis.after_note', { d: self.visitDiff(vm, j.before.visit, j.after.visit) }) }); } else { tiles.push({ label: t('crux.vis.tile_jump.faster', { m: metric }), value: '–', note: t('crux.vis.jump_none', { m: metric }) }); } } return { data: data, state: vis.state, tiles: tiles }; }, visitFmt: function (vm) { if (vm === 'bounce') { return function (v) { return f.num(v, 0) + '%'; }; } if (vm === 'conv') { return function (v) { return f.num(v, v < 10 ? 1 : 0) + '%'; }; } if (vm === 'rps') { return function (v) { return '€ ' + f.num(v, 2); }; } if (vm === 'eng') { return function (v) { v = Math.round(v); return v < 60 ? v + ' s' : Math.floor(v / 60) + ':' + ('0' + (v % 60)).slice(-2) + ' min'; }; } return function (v) { return f.num(v, 2); }; }, // het verschil tussen twee periodes: procentpunten bij percentages, anders procenten visitDiff: function (vm, a, b) { var d = b - a; if (vm === 'bounce' || vm === 'conv') { return t('crux.vis.diff_pt', { v: (d > 0 ? '+' : '') + f.num(d, 1) }); } return a ? (d > 0 ? '+' : '') + f.num(100 * d / a, 0) + '%' : '–'; }, // ------------------------------------------------------------------ Eén metric (/crux/lcp, inp, cls; een tegel op het overzicht) viewDeep: function (d, m, g) { var self = this, metric = t('crux.metric.' + m), device = t('crux.ffl.' + self.ff); var out = { m: m, title: t('crux.deep.title', { m: metric }) }; var n = (d.weeks || []).length, lim = mtCwv.LIMITS[m]; var hasDist = d.dist.good.some(function (v) { return !none(v); }); if (!n || !hasDist) { out.empty = t('crux.deep.nodata', { m: metric, device: device }); return out; } var monthAt = {}; self.months(d.weeks).forEach(function (x) { monthAt[x.i] = x.label; }); var labels = d.weeks.map(function (e, i) { return monthAt[i] || ''; }); var titles = d.weeks.map(function (e) { return self.weekTitle(e); }); var pct = function (v) { return none(v) ? '–' : f.num(v, 0) + '%'; }; var x100 = function (a) { return a.map(function (v) { return none(v) ? null : Math.round(1000 * v) / 10; }); }; // samenvatting: de nieuwste week met een verdeling, en 4 weken eerder var li = -1; for (var i = n - 1; i >= 0; i--) { if (!none(d.dist.good[i])) { li = i; break; } } out.summary = t('crux.deep.summary', { good: pct(100 * d.dist.good[li]), poor: pct(100 * d.dist.poor[li]), limit: limitText(m, lim[0]) }) + (li >= 4 && !none(d.dist.good[li - 4]) ? ' ' + t('crux.deep.summary_was', { was: pct(100 * d.dist.good[li - 4]) }) : ''); out.dist = { labels: labels, titles: titles, series: ['good', 'ni', 'poor'].map(function (k) { return { label: t('ui.cwv.' + k), color: mtCwv.COLORS[k], values: x100(d.dist[k]) }; }), line: { label: t('crux.deep.pass_line'), color: '#111827', values: d.weeks.map(function () { return 75; }) } }; out.pct = pct; // per apparaat var devColors = { PHONE: '#2563eb', DESKTOP: '#0f766e', ALL: '#9ca3af' }; out.devices = { labels: labels, titles: titles, // het gekozen apparaat als laatste: bovenop series: ['ALL', 'DESKTOP', 'PHONE'].filter(function (k) { return k !== self.ff; }).concat([self.ff]).filter(function (k) { return (d.devices[k] || []).some(function (v) { return !none(v); }); }).map(function (k) { return { label: t('crux.deep.dev.' + k), color: devColors[k], values: d.devices[k], width: k === self.ff ? 3 : 2, strong: k === self.ff, dash: k === 'ALL' }; }), guides: [{ label: t('ui.cwv.good') + ' ≤ ' + limitText(m, lim[0]), color: mtCwv.COLORS.good, value: lim[0] }, { label: t('ui.cwv.poor') + ' > ' + limitText(m, lim[1]), color: mtCwv.COLORS.poor, value: lim[1] }] }; out.devFmt = mtCwv.fmtOf(m); var lastOf = function (a) { for (var k = (a || []).length - 1; k >= 0; k--) { if (!none(a[k])) { return a[k]; } } return null; }; var p = lastOf(d.devices.PHONE), dk = lastOf(d.devices.DESKTOP); out.devGap = p !== null && dk !== null ? t('crux.deep.dev_gap', { m: metric, p: mtCwv.fmt(m, p), d: mtCwv.fmt(m, dk) }) : ''; // jij en je concurrenten: aandeel goed in de nieuwste week, beste eerst var rows = [{ domain: g.domain, own: true, dist: { good: d.dist.good[li], ni: d.dist.ni[li], poor: d.dist.poor[li] } }].concat((d.comps || []).map(function (c) { return { domain: c.domain, own: false, dist: c.dist }; })); rows.sort(function (a, b) { return (b.dist.good || 0) - (a.dist.good || 0); }); out.comps = rows.map(function (r) { return { domain: r.domain, own: r.own, parts: mtCwv.distParts(r.dist), good: pct(100 * r.dist.good) }; }); out.hasComps = (d.comps || []).length > 0; out.compSub = t('crux.deep.comp_sub', { device: device }); out.compNone = t('crux.deep.comp_none', { m: metric }); // LCP: de stappen per week, het soort grootste element, hoe bezoekers binnenkomen if (d.parts) { var anyPart = PARTS.some(function (k) { return (d.parts[k] || []).some(function (v) { return !none(v); }); }); if (anyPart) { // Arjen (2026-10-29, brabbels): gestapeld leek het één LCP van 2 s terwijl die 0,59 s was. Elke stap is // zijn eigen 75e percentiel (die tellen niet op) en alleen van afbeeldingen: daarom losse lijnen out.parts = { labels: labels, titles: titles, series: PARTS.map(function (k) { return { label: t('crux.part.' + k), color: PART_COLORS[k], values: d.parts[k], width: 2.5 }; }) }; var img = d.lcp_type && d.lcp_type.image; out.partsSub = img ? t('crux.deep.parts_sub_share', { share: f.num(100 * img, 0) + '%' }) : t('crux.deep.parts_sub'); out.partsFmt = mtCwv.ms; var big = null; PARTS.forEach(function (k) { var v = lastOf(d.parts[k]); if (v !== null && (!big || v > big.v)) { big = { k: k, v: v }; } }); out.partsBig = big ? t('crux.deep.parts_big', { part: t('crux.part.' + big.k).toLowerCase(), v: mtCwv.ms(big.v) }) : ''; } } if (d.lcp_type) { var typeColors = { image: '#7c3aed', text: '#94a3b8' }; out.lcpType = Object.keys(d.lcp_type).map(function (k) { return { label: t('crux.deep.type.' + k), value: 100 * d.lcp_type[k], color: typeColors[k] || '#cbd5e1', text: f.num(100 * d.lcp_type[k], 0) + '%' }; }); } if (d.nav && Object.keys(d.nav).length) { out.nav = { labels: labels, titles: titles, series: NAV.filter(function (k) { return d.nav[k[0]]; }).map(function (k) { return { label: t('crux.nav.' + k[0]), color: k[1], values: x100(d.nav[k[0]]) }; }) }; } return out; }, // ------------------------------------------------------------------ Diagnose viewDiagnose: function (g) { var self = this, maxSum = 0; var staleNote = function (l) { return l && l.stale ? self.weekTitle(l.end) : ''; }; g.rows.forEach(function (r) { if (r.parts) { maxSum = Math.max(maxSum, PARTS.reduce(function (a, k) { return a + (r.parts.v[k] || 0); }, 0)); } }); var rating = function (m, v) { return mtCwv.rating(m, v) || 'none'; }; var val = function (l) { return l ? l.v : null; }; return { findings: (g.findings || []).map(function (x) { return self.findingText(x); }), partsMax: maxSum ? mtChart.niceMax(maxSum) : 0, partsLegend: PARTS.map(function (k) { return { label: t('crux.part.' + k), color: PART_COLORS[k] }; }), navLegend: NAV.filter(function (k) { return k[0] !== 'restore'; }).map(function (k) { return { label: t('crux.nav.' + k[0]), color: k[1] }; }), ms: mtCwv.ms, rows: g.rows.map(function (r) { var parts = r.parts ? PARTS.map(function (k) { var v = r.parts.v[k]; return { label: t('crux.part.' + k), value: v, color: PART_COLORS[k], text: mtCwv.ms(v) }; }) : null; var nav = r.nav ? NAV.filter(function (k) { return r.nav.v[k[0]] > 0; }).map(function (k) { var v = r.nav.v[k[0]]; return { label: t('crux.nav.' + k[0]), value: 100 * v, color: k[1], ink: k[2], text: v >= 0.06 ? f.num(100 * v, 0) + '%' : '' }; }) : null; var navNote = ''; if (r.nav) { var fast = (r.nav.v.back_forward_cache || 0) + (r.nav.v.prerender || 0), back = r.nav.v.back_forward || 0; navNote = back > fast ? t('crux.diag.nav_back', { pct: f.num(100 * back, 0) }) : t('crux.diag.nav_fast', { pct: f.num(100 * fast, 0) }); } var dens = val(r.ad_density); return { domain: r.domain, own: r.own, pending: r.pending, parts: parts, partsSum: parts ? mtCwv.ms(PARTS.reduce(function (a, k) { return a + (r.parts.v[k] || 0); }, 0)) : '–', partsNote: staleNote(r.parts), nav: nav, navNote: navNote, navStale: staleNote(r.nav), ttfb: mtCwv.ms(val(r.ttfb)), ttfbR: rating('ttfb', val(r.ttfb)), fcp: mtCwv.ms(val(r.fcp)), fcpR: rating('fcp', val(r.fcp)), rtt: none(val(r.rtt)) ? '–' : f.num(val(r.rtt)) + ' ms', netStale: staleNote(r.ttfb), dens: none(dens) ? '–' : f.num(dens, 0) + '%', densW: none(dens) ? 0 : Math.min(100, 100 * dens / 25), kb: none(val(r.ad_kb)) ? '–' : f.num(val(r.ad_kb) / 1000, 1) + ' MB', cpu: mtCwv.ms(val(r.ad_cpu)), adsStale: staleNote(r.ad_density) || staleNote(r.ad_cpu), hasAds: !none(dens) || !none(val(r.ad_kb)) || !none(val(r.ad_cpu)) }; }) }; }, // een bevinding (crux::findings) naar het kaartje findingText: function (x) { var part = x.part ? t('crux.part.' + x.part).toLowerCase() : ''; var pct = function (v) { return f.num(100 * v, 0); }; switch (x.kind) { case 'ads_own': return { kind: t('crux.find.kind.ads'), color: '#2563eb', head: t('crux.find.ads_own', { pct: x.pct }), text: t(x.safe ? 'crux.find.ads_own_safe' : 'crux.find.ads_own_risk', { min: x.min, max: x.max }) }; case 'ads_comp': return { kind: t('crux.find.kind.ads'), color: '#2563eb', head: t('crux.find.ads_comp', { domain: x.domain, pct: x.pct }), text: x.own === null ? '' : t('crux.find.ads_comp_own', { pct: x.own }) }; case 'part_comp': return { kind: t('crux.find.kind.comp'), color: PART_COLORS[x.part], head: t('crux.find.part_comp', { domain: x.domain, v: mtCwv.ms(x.value), part: part }), text: t('crux.find.why.' + x.part) + (x.ahead ? ' ' + t('crux.find.ahead') : '') }; case 'part_own': return { kind: t('crux.find.kind.own'), color: PART_COLORS[x.part], head: t('crux.find.part_own', { v: mtCwv.ms(x.value), part: part }), text: t('crux.find.fix.' + x.part) }; case 'bfcache_comp': case 'bfcache_own': return { kind: t(x.kind === 'bfcache_own' ? 'crux.find.kind.own' : 'crux.find.kind.comp'), color: '#f59e0b', head: t('crux.find.' + x.kind, { domain: x.domain }), text: t('crux.find.' + x.kind + '_text', { pct: pct(x.back) }) + (x.best ? ' ' + t('crux.find.bfcache_best', { domain: x.best, pct: pct(x.best_share) }) : '') }; case 'ttfb_own': return { kind: t('crux.find.kind.server'), color: '#0f766e', head: t('crux.find.ttfb_own', { v: mtCwv.ms(x.value) }), text: t('crux.find.ttfb_own_text') }; default: return { kind: '', head: '', text: '' }; } } }; // de vertalingen kunnen later binnenkomen dan de gegevens: dan alleen opnieuw tekenen var offTranslate = $rootScope.$on('$translateChangeSuccess', function () { c.tabs = c.tabs.map(function (x) { return x.metric ? x : angular.extend({}, x, { label: t('crux.tab.' + x.key) }); }); c.refresh(); }); $scope.$on('$destroy', offTranslate); c.init(); }]); // Analytics (GA4), voor iedereen sinds 2026-11-03: /ga4//. De originele Analytics-pagina's (Kanalen, // Publiek, Pagina's, Gebeurtenissen, Conversies met hun weergaven; lijn, verdeling, tegels per metriek, tabel met alle // metrieken) in de nieuwe opmaak, op de nieuwe API (analytics.view), met doorklikken: een regel aanklikken zet een filter // (kanaal, bron, pagina, land, apparaat, map) dat op elke pagina blijft gelden tot je het weghaalt (Arjen 2026-11-02: "de // eerste versie in een mooie layout, met de doorklikfilters erbij"). Organic › google › pagina's is zo twee klikken. // Was proef v5 (/analytics-v5); de andere proefversies staan los in js/controller/proef/ (docs/proef/README.md). var ga4State = { site: null, cache: {} }; // Analytics in een rapport (newlayout/ga4/report.html): de periode van het rapport ($rootScope.reportdates) tegenover de // periode ervoor, op de publieke endpoints (analytics.summary, channels, sources). custominit.urlview: all = alle kanalen, // anders één kanaal met zijn bronnen; other = de kanalen die geen eigen sectie hebben. Vervangt de oude GA4-secties. app.controller('ga4Report', ['$scope', '$rootScope', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $http, $translate, h, mtFmt) { var CH = { organic: 'Organic Search', paid: 'Paid Search', display: 'Display', social: 'Organic Social', referral: 'Referral', email: 'Email' }; var view = ($scope.custominit && $scope.custominit.urlview) || 'all'; var channel = CH[view] || null; var t = function (k, v) { return $translate.instant(k, v); }; var r = $scope.r = { f: mtFmt, rows: null, chart: null, kpis: [1, 2, 3, 4].map(function () { return { label: '', loading: true }; }), state: 'loading' }; var day = function (v) { v = +v; if (!v) { return null; } var d = new Date(v > 10000000000 ? v : v * 1000); return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }; var dates = $rootScope.reportdates || {}, from = day(dates.start), to = day(dates.end); // GA4 is van vandaag nog niet compleet: een rapport van de lopende maand loopt tot gisteren (zoals AI Insights) var yesterday = day(Date.now() / 1000 - 86400); if (to && to > yesterday) { to = yesterday; } if (from && to && from > to) { from = to = null; } var body = function (extra) { var b = angular.extend({ site_id: h.siteId() }, from && to ? { from: from, to: to } : { days: 28 }, extra || {}); if (channel) { b.channel = channel; } return b; }; var fail = function (res) { r.state = res && res.status === 409 ? 'connect' : 'error'; }; $translate.onReady().then(function () { r.title = channel ? t('ga4n.sources') : t('ga4n.channels'); r.col = channel ? t('ga4n.col.source') : t('ga4n.col.channel'); $http.post('/rest/v1/analytics.summary', body()).then(function (res) { var d = res.data.data, k = function (m, label, pt) { var x = d[m] || {}; return { label: t('ga4n.m.' + label), value: pt ? mtFmt.pct(x.value) : mtFmt.num(x.value), delta: pt ? x.change : x.change_pct, unit: pt ? 'pt' : undefined }; }; r.state = 'ok'; r.kpis = [k('sessions', 'sessions'), k('engaged_sessions', 'engaged'), k('users', 'users'), k('conversions', 'key_events')]; var days = d.daily || []; r.chart = { labels: days.map(function (x, i) { return i % 7 === 0 ? mtFmt.dayWeek(x.date) : ''; }), titles: days.map(function (x) { return mtFmt.dayWeek(x.date); }), series: [{ label: t('ga4n.m.sessions'), color: '#2563eb', values: days.map(function (x) { return x.sessions; }) }, { label: t('ga4n.prev.prev'), color: '#9ca3af', values: days.map(function (x) { return x.previous_sessions; }) }] }; }, fail); $http.post('/rest/v1/analytics.' + (channel ? 'sources' : 'channels'), body({ limit: 25 })).then(function (res) { var named = Object.keys(CH).map(function (k) { return CH[k]; }); r.rows = (res.data.data || []).filter(function (x) { return view !== 'other' || named.indexOf(x.channel) < 0; }).map(function (x) { var prev = x.previous ? x.previous.sessions : null; return { label: channel ? x.source_medium : x.channel, sessions: x.sessions, engaged: x.engaged_sessions, rate: x.engagement_rate_pct, conversions: x.conversions, delta: prev ? 100 * (x.sessions - prev) / prev : null }; }); }, function (res) { r.rows = []; fail(res); }); }); }]); app.controller('ga4', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt) { var BASE = '/ga4'; // de rapporten en hun weergaven, zoals in het menu van de originele pagina's var REPORTS = { kanalen: ['alle', 'organic', 'ai', 'direct', 'paid', 'social', 'referral', 'email'], publiek: ['landen', 'apparaten', 'browsers', 'taal', 'nieuw'], paginas: ['landing', 'alle', 'mappen'], gebeurtenissen: ['alle'], conversies: ['kanalen', 'paginas', 'producten'] }; // een kanaalweergave is een kanaal; andere kanalen (uit de tabel Alle) openen de weergave 'kanaal' met ?channel= var CHANNEL_VIEW = { organic: 'Organic Search', ai: 'AI Assistant', direct: 'Direct', paid: 'Paid Search', social: 'Organic Social', referral: 'Referral', email: 'Email' }; // kanalen waar je eerst de pagina's ziet (de bron is bijna altijd dezelfde zoekmachine); bij de rest eerst de bronnen var PAGES_FIRST = ['Organic Search', 'AI Assistant', 'Direct', 'Paid Search']; // kanalen waar een bron een site is die naar je linkt: ingezoomd op een bron staan de verwijzende pagina's ernaast var REF = ['Referral', 'Organic Social', 'Paid Social', 'AI Assistant', 'Email', 'Organic Video', 'Display', 'Affiliates']; var AUDIENCE_DIM = { landen: 'country', apparaten: 'deviceCategory', browsers: 'browser', taal: 'language', nieuw: 'newVsReturning' }; // filters in de URL (doorklikken); gelden op elke pagina var FILTERS = ['channel', 'source', 'page', 'folder', 'country', 'device']; // elke tegel kan de lijn zijn (Arjen 2026-11-03: "als ik op een card klik wil ik dat de grafiek zich altijd aanpast") var DAILY = ['sessions', 'engaged', 'eng_rate', 'users', 'new_users', 'views', 'vps', 'duration', 'key_events', 'revenue']; // wat je per dag of week optelt; duur is een gemiddelde (gewogen naar sessies), de rest volgt uit de sommen (val) var SUMS = ['sessions', 'engaged', 'users', 'new_users', 'views', 'key_events', 'revenue']; var RATIO = ['eng_rate', 'vps', 'duration', 'conv']; // de periodes (analytics::DAYS): 1 = vandaag; 90, 365 en 730 zijn onder water hele weken (91, 364, 728 dagen) var DAYS = [1, 7, 28, 90, 365, 730]; var RANGE_LABEL = { 1: 'ga4.range.today', 365: 'ga4.range.year', 730: 'ga4.range.two_years' }; var BLUE = '#2563eb', GREY = '#9ca3af'; // de rechterkaart: categoriekleuren in vaste volgorde (docs/ui.md, mt-donut), de rest grijs var CAT = ['#2563eb', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#6250d6'], REST = '#d1d5db'; var PAGE = 25; // het hoofdgetal (sessies of betrokken sessies), zelfde keuze als de eerste nieuwe versie var PRIM_KEY = 'mt.ga4n.prim'; var prim = 'sessions'; try { prim = window.localStorage.getItem(PRIM_KEY) === 'engaged' ? 'engaged' : 'sessions'; } catch (e) { prim = 'sessions'; } var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (ga4State.site !== h.siteId()) { ga4State = { site: h.siteId(), cache: {} }; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; var dur = function (s) { if (none(s)) { return '–'; } s = Math.round(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; // een metriek van een regel (cur of prev), ook de afgeleide var val = function (m, c) { if (!c) { return null; } if (m === 'vps') { return c.sessions ? (c.views || 0) / c.sessions : null; } if (m === 'eng_rate') { return c.sessions ? 100 * (c.engaged || 0) / c.sessions : null; } if (m === 'conv') { return c.sessions ? 100 * (c.key_events || 0) / c.sessions : null; } return c[m] === undefined ? null : c[m]; }; var show = function (m, v) { if (none(v)) { return '–'; } if (m === 'duration') { return dur(v); } if (m === 'eng_rate' || m === 'conv') { return f.pct(v, m === 'conv' ? 1 : 0); } if (m === 'vps') { return f.num(v, 2); } if (m === 'revenue') { return f.euro(v); } return f.num(v); }; // de regels van een deel (Publiek levert {dim, rows}) en hun naam in de rechterkaart var sideRows = function (data) { return (data && data.rows ? data.rows : data) || []; }; var sideLabel = function (S, r) { if (S.kind === 'device') { return t('ga4n.device.' + r.key); } if (S.kind === 'source') { return String(r.key).split(' / ')[0]; } if (S.dim === 'newVsReturning') { return t('ga4.side.nv.' + r.key); } return r.key; }; // procentpunten voor percentages, anders procenten var diff = function (m, cur, prev) { var a = val(m, cur), b = val(m, prev); if (none(a) || none(b)) { return null; } if (m === 'eng_rate' || m === 'conv') { return { v: a - b, unit: 'pt' }; } return b ? { v: 100 * (a - b) / b, unit: '%' } : null; }; var c = $scope.c = { f: f, show: show, report: REPORTS[$routeParams.report] ? $routeParams.report : 'kanalen', q: {}, d: {}, prim: prim, v: {}, limit: PAGE, reports: [], views: [], dayOpts: [], compareOpts: [], // koppelen: direct naar Google en terug naar deze pagina om de property te kiezen (Arjen 2026-11-03: de oude // Analytics-pagina's zijn weg). Mag iedereen met toegang tot de site, ook een gast (siteConnections::canManage). connectUrl: '/rest/analytics4/connect?websiteid=' + h.siteId() + '&h=' + encodeURIComponent(BASE + '/kanalen?kies=1'), conn: { list: null, loading: false, error: false, q: '' }, loadProps: function () { var self = this; this.conn.loading = true; this.conn.error = false; $http.post('/rest/v1/analytics.properties', { site_id: h.siteId() }).then(function (res) { var d = res.data.data, groups = {}; self.conn.connected = d.connected; self.conn.current = d.property && d.property.indexOf('properties/') !== 0 ? 'properties/' + d.property : d.property; self.conn.list = d.properties; d.properties.forEach(function (p) { (groups[p.account] = groups[p.account] || []).push(p); }); self.conn.groups = Object.keys(groups).map(function (a) { return { account: a, items: groups[a] }; }); self.conn.loading = false; }, function () { self.conn.loading = false; self.conn.error = true; self.conn.list = []; self.conn.groups = []; }); }, propMatch: function (p) { var q = (this.conn.q || '').toLowerCase(); return !q || (p.name + ' ' + p.account + ' ' + p.property).toLowerCase().indexOf(q) > -1; }, choose: function (p) { var self = this; this.conn.saving = p.property; $http.post('/rest/v1/analytics.property_set', { site_id: h.siteId(), property: p.property }).then(function () { self.conn.saving = null; self.conn.current = p.property; ga4State.cache = {}; $location.search('kies', null); self.picking = false; self.load(); }, function () { self.conn.saving = null; self.conn.error = true; }); }, changeProperty: function () { $location.search('kies', 1); }, cancelPick: function () { $location.search('kies', null); }, init: function () { var self = this; // eerst laden: onReady kan meteen afgaan als de vertalingen er al zijn this.load(); $translate.onReady(function () { self.dayOpts = DAYS.map(function (n) { return { v: n, l: RANGE_LABEL[n] ? t(RANGE_LABEL[n]) : t('ga4n.days', { n: n }) }; }); self.setCompareOpts(); self.nav(); self.refresh(); }); }, // 2 jaar tegenover 364 dagen eerder zou overlappen: dan alleen de periode ervoor (de server doet hetzelfde); // vandaag staat tegenover dezelfde weekdag vorige week setCompareOpts: function () { var self = this; this.compareOpts = (this.days === 730 ? ['prev'] : ['prev', 'year']).map(function (k) { return { v: k, l: t(k === 'prev' && self.days === 1 ? 'ga4.range.prev_week' : 'ga4n.prev.' + k) }; }); }, // ---- waar je bent ---- readUrl: function () { var s = $location.search(), q = {}; FILTERS.forEach(function (k) { if (s[k]) { q[k] = String(s[k]); } }); this.view = REPORTS[this.report].indexOf($routeParams.view) > -1 || ($routeParams.view === 'kanaal' && q.channel) ? $routeParams.view : REPORTS[this.report][0]; // een kanaalweergave bepaalt het kanaal zelf if (this.report === 'kanalen') { if (CHANNEL_VIEW[this.view]) { q.channel = CHANNEL_VIEW[this.view]; } if (this.view === 'alle') { delete q.channel; delete q.source; } } this.q = q; this.days = DAYS.indexOf(+s.days) > -1 ? +s.days : 28; // ?kies=1: de property kiezen (na het koppelen, of via "Property wijzigen") this.picking = !!s.kies; if (this.picking && !this.conn.list && !this.conn.loading) { this.loadProps(); } this.compare = s.compare === 'year' && this.days !== 730 ? 'year' : 'prev'; if (this.compareOpts.length) { this.setCompareOpts(); } }, // de weergave die bij een kanaal hoort channelView: function (ch) { for (var k in CHANNEL_VIEW) { if (CHANNEL_VIEW[k] === ch) { return k; } } return 'kanaal'; }, href: function (report, view, filters) { var s = $location.search(), parts = [], q = angular.extend({}, this.q, filters || {}); // het kanaal van een kanaalweergave staat in het pad, niet in de query if (report === 'kanalen' && (CHANNEL_VIEW[view] || view === 'alle')) { delete q.channel; } if (report === 'kanalen' && view === 'alle') { delete q.source; } FILTERS.forEach(function (k) { if (q[k]) { parts.push(k + '=' + encodeURIComponent(q[k])); } }); ['days', 'compare'].forEach(function (k) { if (s[k]) { parts.push(k + '=' + encodeURIComponent(s[k])); } }); return BASE + '/' + report + '/' + view + (parts.length ? '?' + parts.join('&') : ''); }, go: function (report, view, filters) { $location.url(this.href(report, view, filters)); }, set: function (k, v) { $location.search(k, v === null || v === undefined || v === '' ? null : v); }, removeFilter: function (k) { if (k === 'channel' && this.report === 'kanalen') { this.go('kanalen', 'alle', { channel: null, source: null }); return; } var x = {}; x[k] = null; if (k === 'channel') { x.source = null; } this.go(this.report, this.view, x); }, clearFilters: function () { var x = {}; FILTERS.forEach(function (k) { x[k] = null; }); this.go(this.report, this.report === 'kanalen' ? 'alle' : this.view, x); }, nav: function () { var self = this; this.reports = Object.keys(REPORTS).map(function (r) { return { key: r, label: t('ga4.r.' + r), href: self.href(r, REPORTS[r][0]) }; }); this.views = REPORTS[this.report].map(function (v) { return { key: v, label: t('ga4.v.' + self.report + '.' + v), href: self.href(self.report, v) }; }); if (this.view === 'kanaal') { this.views.push({ key: 'kanaal', label: this.q.channel, href: '' }); } }, // ---- wat er op deze weergave staat: de tabel en de verdeling ernaast ---- // {part, dim?, kind, sort?} kind = wat een klik op een regel doet layout: function () { var r = this.report, v = this.view, q = this.q; var L = { table: null, side: null, chart: this.prim }; if (r === 'kanalen') { // de rechterkaart beantwoordt de vraag van deze weergave, in de vorm die daarbij past (Arjen 2026-11-03); // from: 'table' = uit de data van de tabel, geen extra vraag aan Google if (!q.channel) { L.table = { part: 'channels', kind: 'channel' }; L.side = { from: 'table', viz: 'shift', title: 'ga4.side.shift', by: 'channel' }; } else if (q.source || (q.page && PAGES_FIRST.indexOf(q.channel) < 0)) { L.table = q.page ? { part: 'devices', kind: 'device' } : { part: 'landing', kind: 'page' }; L.side = q.source && REF.indexOf(q.channel) > -1 ? { part: 'referrers', kind: 'page' } : { part: 'devices', kind: 'device' }; } else if (PAGES_FIRST.indexOf(q.channel) > -1) { L.table = q.page ? { part: 'sources', kind: 'source' } : { part: 'landing', kind: 'page' }; L.side = q.page ? (q.channel === 'Direct' ? { part: 'audience', dim: 'country', kind: 'country' } : { part: 'sources', kind: 'source' }) : ({ 'Organic Search': { part: 'sources', kind: 'source', viz: 'donut', title: 'ga4.side.engines', by: 'engine' }, 'AI Assistant': { part: 'sources', kind: 'source', viz: 'donut', title: 'ga4.side.ais', by: 'ai' }, 'Direct': { part: 'audience', dim: 'newVsReturning', viz: 'donut', title: 'ga4.side.newret', by: 'newret' }, 'Paid Search': { part: 'campaigns', viz: 'bars', title: 'ga4.side.campaigns', by: 'campaign' } }[q.channel] || { part: 'sources', kind: 'source' }); } else { L.table = { part: 'sources', kind: 'source' }; L.side = q.channel === 'Organic Social' ? { from: 'table', kind: 'source', viz: 'donut', title: 'ga4.side.networks', by: 'network' } : (q.channel === 'Email' ? { part: 'campaigns', viz: 'bars', title: 'ga4.side.campaigns', by: 'campaign' } : { part: 'landing', kind: 'page' }); } } if (r === 'publiek') { var dim = AUDIENCE_DIM[v]; if (dim === 'country' && q.country) { dim = 'city'; } if (dim === 'deviceCategory' && q.device) { dim = 'browser'; } L.table = { part: 'audience', dim: dim, kind: { country: 'country', deviceCategory: 'device' }[dim] || null }; L.side = { country: { from: 'table', kind: 'country', viz: 'donut', title: 'ga4.side.countries', by: 'country' }, city: { part: 'devices', kind: 'device' }, deviceCategory: { from: 'table', kind: 'device', viz: 'donut', title: 'ga4.side.devices', by: 'device' }, browser: { from: 'table', viz: 'donut', title: 'ga4.side.browsers', by: 'browser' }, language: { from: 'table', viz: 'donut', title: 'ga4.side.languages', by: 'language' }, newVsReturning: { part: 'split', dim: 'newVsReturning', viz: 'lines', title: 'ga4.side.newret_line', by: 'newret' } }[dim] || { part: 'channels', kind: 'channel' }; } if (r === 'paginas') { // met een pagina gekozen: waar het bezoek van die pagina vandaan komt if (q.page) { L.table = { part: q.channel ? 'sources' : 'channels', kind: q.channel ? 'source' : 'channel' }; } else if (v === 'alle') { L.table = { part: 'pages', kind: null, sort: 'views' }; } else if (v === 'mappen') { L.table = { part: 'tree', kind: 'tree' }; } else { L.table = { part: 'landing', kind: 'page' }; } L.side = q.page ? { part: 'devices', kind: 'device' } : (v === 'mappen' ? { from: 'table', kind: 'tree', viz: 'donut', title: 'ga4.side.folders', by: 'folder' } : (v === 'alle' ? { part: 'channels', kind: 'channel' } : { from: 'table', viz: 'concentration', title: 'ga4.side.top10', by: 'page' })); } if (r === 'gebeurtenissen') { L.table = { part: 'events', kind: null, sort: 'events' }; L.chart = 'key_events'; } if (r === 'conversies') { L.chart = 'key_events'; if (v === 'kanalen') { L.table = { part: 'channels', kind: 'channel', sort: 'key_events' }; L.side = { from: 'table', kind: 'channel', viz: 'rate', title: 'ga4.side.rate_channel', by: 'channel' }; } if (v === 'paginas') { L.table = { part: 'landing', kind: 'page', sort: 'key_events', limit: 500 }; L.side = { from: 'table', kind: 'page', viz: 'rate', title: 'ga4.side.rate_page', by: 'page' }; } if (v === 'producten') { L.table = { part: 'items', kind: null, sort: 'revenue' }; L.side = { from: 'table', viz: 'donut', metric: 'revenue', title: 'ga4.side.products', by: 'product' }; } } return L; }, body: function (s) { var q = this.q, filters = {}; FILTERS.forEach(function (k) { if (q[k]) { filters[k] = q[k]; } }); var b = { site_id: h.siteId(), part: s.part, days: this.days, compare: this.compare, prim: this.prim, filters: filters }; if (s.dim) { b.dim = s.dim; } if (s.limit) { b.limit = s.limit; } return b; }, fetch: function (slot, s, gen) { var self = this, body = this.body(s), key = JSON.stringify(body); var put = function (data) { if (gen === self.gen) { self.d[slot] = data; self.refresh(); } }; if (ga4State.cache[key]) { put(ga4State.cache[key]); } $http.post('/rest/v1/analytics.view', body).then(function (res) { ga4State.cache[key] = res.data.data; put(res.data.data); }, function (res) { put({ state: 'error', message: res.status === 403 ? t('ga4n.no_access') : '' }); }); }, snapshot: function () { return JSON.stringify([this.report, this.view, this.q, this.days, this.compare]); }, load: function () { var gen = (this.gen = (this.gen || 0) + 1); this.readUrl(); this.loaded = this.snapshot(); this.L = this.layout(); this.d = {}; this.limit = PAGE; this.sort = this.L.table.sort || this.prim; this.metric = this.L.chart; // de tegels en de lijn: één aanroep, één vraag aan Google (de totalen zitten in hetzelfde antwoord) this.fetch('head', { part: 'head' }, gen); this.fetch('table', this.L.table, gen); if (this.L.side && this.L.side.from !== 'table') { this.fetch('side', this.L.side, gen); } this.refresh(); }, // ---- klikken ---- setPrim: function (p) { if (p === this.prim) { return; } this.prim = p; try { window.localStorage.setItem(PRIM_KEY, p); } catch (e) { /* privévenster */ } this.load(); }, pick: function (m) { this.metric = m; if (this.v.columns && this.v.columns.indexOf(m) > -1) { this.sort = m; } this.refresh(); }, sortBy: function (m) { this.sort = m; this.refresh(); }, more: function () { this.limit += 50; this.refresh(); }, // een regel aanklikken = een filter zetten (en de weergave die daarbij hoort) drill: function (kind, row) { if (!kind || !row) { return; } var k = row.key; if (kind === 'channel') { var cv = this.channelView(k); this.go('kanalen', cv, { channel: cv === 'kanaal' ? k : null, source: null }); return; } if (kind === 'tree') { this.go('paginas', row.kind === 'folder' ? 'mappen' : 'landing', row.kind === 'folder' ? { folder: k } : { page: k }); return; } var x = {}; x[kind] = k; this.go(this.report, this.view, x); }, pageUrl: function (path) { return 'https://' + ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/\/$/, '') + path; }, // ---- wat de pagina toont ---- refresh: function () { var self = this, d = this.d, v = {}, q = this.q; var part = function (slot) { var x = d[slot]; return x ? (x.state === 'ok' ? x[x.part] : null) : undefined; }; var head = part('head'), SLOTS = ['head', 'table', 'side']; v.state = SLOTS.some(function (k) { return d[k] && d[k].state === 'connect'; }) ? 'connect' : SLOTS.some(function (k) { return d[k] && d[k].state === 'error'; }) ? 'error' : 'ok'; // niet gekoppeld (naar Google) of wel een account maar geen property (kiezen) v.connect = v.state === 'connect' ? (SLOTS.map(function (k) { return d[k] && d[k].connect; }).filter(Boolean)[0] || 'not_connected') : null; if (v.connect === 'no_property' && !this.conn.list && !this.conn.loading) { this.loadProps(); } v.measures = (d.head && d.head.measures) || {}; if (d.head && d.head.period) { var p = d.head.period; // met jaartal zodra een periode over de jaargrens loopt ("4 nov 2025 – 2 nov 2026") var years = [p.cur.from, p.cur.to, p.prev.from, p.prev.to].map(function (x) { return x.slice(0, 4); }); var dy = years.some(function (y) { return y !== years[1]; }) ? function (x) { return f.day(x) + ' ' + x.slice(0, 4); } : f.day; v.periodText = p.today ? t('ga4.range.today_text', { hour: ('0' + p.hour).slice(-2), prev: f.dayWeek(p.prev.from) }) : t('ga4n.period.' + p.compare, { from: dy(p.cur.from), to: dy(p.cur.to), pfrom: dy(p.prev.from), pto: dy(p.prev.to) }); } v.chips = FILTERS.filter(function (k) { return q[k] && !(self.report === 'kanalen' && k === 'channel' && CHANNEL_VIEW[self.view]); }) .map(function (k) { return { key: k, label: t(k === 'folder' ? 'ga4.folder' : 'ga4n.filter.' + k), value: k === 'device' ? t('ga4n.device.' + q[k]) : q[k] }; }); v.title = this.title(); if (head) { v.tiles = this.tiles(head.totals, v.measures); v.chart = this.chart(head.daily); } v.table = this.table(part('table'), v.measures); if (v.table) { v.columns = v.table.columns; } v.side = this.L.side ? this.side(part(this.L.side.from === 'table' ? 'table' : 'side'), head) : null; this.v = v; }, // de kop boven de tabel: wat je ziet, en voor welk filter title: function () { var L = this.L.table, key = L.part === 'audience' ? 'audience.' + L.dim : L.part; return { text: t('ga4.t.' + key), sub: L.kind ? t('ga4.click.' + L.kind) : '' }; }, tiles: function (tot, measures) { var cur = tot.cur, prev = tot.prev, self = this; var list = ['sessions', 'engaged', 'eng_rate', 'users', 'new_users', 'views', 'vps', 'duration']; if (measures.key_events) { list.push('key_events'); } if (measures.revenue) { list.push('revenue'); } return list.map(function (m) { var dd = diff(m, cur, prev); return { key: m, label: self.label(m), value: show(m, val(m, cur)), delta: dd ? dd.v : null, unit: dd ? dd.unit : '%' }; }); }, pickTitle: function () { return t('ga4n.pick'); }, label: function (m) { return ['new_users', 'vps', 'conv', 'purchased', 'viewed'].indexOf(m) > -1 ? t('ga4.m.' + m) : t('ga4n.m.' + m); }, // de lijn van de gekozen tegel: vandaag per uur, tot 90 dagen per dag, een jaar en langer per week (anders 364 of // 728 punten). Een punt = de sommen over zijn dagen; afgeleide metrieken (betrokkenheid, weergaven per sessie) en // de duur (gewogen naar sessies) volgen daaruit. chart: function (daily) { var m = DAILY.indexOf(this.metric) > -1 ? this.metric : 'sessions'; var span = !daily.hourly && daily.dates.length > 120 ? 7 : 1, idx = []; for (var i = 0; i < daily.dates.length; i += span) { idx.push(i); } var point = function (side, i) { var d = daily[side], o = {}, dw = 0; SUMS.forEach(function (k) { o[k] = (d[k] || []).slice(i, i + span).reduce(function (s, v) { return s + (v || 0); }, 0); }); for (var j = i; j < i + span && j < daily.dates.length; j++) { dw += ((d.duration || [])[j] || 0) * ((d.sessions || [])[j] || 0); } o.duration = o.sessions ? dw / o.sessions : null; return val(m, o); }; var cur = idx.map(function (i) { return point('cur', i); }), prev = idx.map(function (i) { return point('prev', i); }); var dates = idx.map(function (i) { return daily.dates[i]; }), prevDates = idx.map(function (i) { return daily.prev_dates[i]; }); var label = daily.hourly ? function (x) { return x + ':00'; } : (span > 1 ? function (x) { return t('ga4.range.week', { d: f.dayWeek(x) }); } : function (x) { return f.dayWeek(x); }); var n = dates.length, step = daily.hourly ? 3 : (n > 31 ? (n > 60 ? 8 : 14) : 7); this.chartMetric = m; this.chartPer = daily.hourly ? 'ga4.range.per_hour' : (span > 1 ? 'ga4.range.per_week' : 'ga4n.per_day'); this.chartFmt = function (v) { return show(m, v); }; this.chartInts = ['eng_rate', 'vps', 'duration', 'revenue'].indexOf(m) < 0; return { labels: dates.map(function (x, i) { return i % step === 0 ? label(x) : ''; }), titles: dates.map(function (x, i) { return label(x) + (daily.hourly ? '' : ' vs ' + label(prevDates[i])); }), series: [ { label: this.label(m) + ' · ' + t('ga4n.now'), color: BLUE, values: cur, strong: true }, { label: t(this.days === 1 && this.compare === 'prev' ? 'ga4.range.prev_week' : 'ga4n.prev.' + this.compare), color: GREY, values: prev, dash: true } ] }; }, // de tabel: alle metrieken als kolommen, sorteren op een kolom, het verschil bij de gesorteerde kolom table: function (data, measures) { var L = this.L.table, self = this; if (data === undefined) { return { loading: true, rows: [], columns: [] }; } var rows = (L.part === 'audience' ? (data && data.rows) : data) || []; var cols; if (L.part === 'events') { cols = ['events', 'key_events', 'users', 'revenue']; } else if (L.part === 'items') { cols = ['revenue', 'purchased', 'viewed']; } else if (L.part === 'pages') { cols = ['views', 'sessions', 'users', 'eng_rate', 'duration']; } else if (this.report === 'conversies') { cols = ['key_events', 'conv', 'revenue', 'sessions', 'eng_rate']; } else { cols = ['sessions', 'engaged', 'eng_rate', 'users', 'new_users', 'views', 'vps', 'duration']; if (measures.key_events) { cols.push('key_events'); } if (measures.revenue) { cols.push('revenue'); } } if (!measures.revenue) { cols = cols.filter(function (m) { return m !== 'revenue' || L.part === 'items'; }); } if (cols.indexOf(this.sort) < 0) { this.sort = cols[0]; } var sort = this.sort; rows = rows.filter(function (r) { return r.cur && cols.some(function (m) { return (val(m, r.cur) || 0) > 0; }); }); if (this.report === 'conversies' && L.part !== 'items') { rows = rows.filter(function (r) { return (r.cur.key_events || 0) > 0; }); } rows = rows.slice().sort(function (a, b) { return (val(sort, b.cur) || 0) - (val(sort, a.cur) || 0); }); var tot = rows.reduce(function (a, r) { return a + (val(sort, r.cur) || 0); }, 0); var share = ['eng_rate', 'conv', 'vps', 'duration'].indexOf(sort) < 0; return { columns: cols, total: rows.length, rows: rows.slice(0, this.limit).map(function (r) { var label = r.key; if (L.part === 'audience' && L.dim === 'deviceCategory') { label = t('ga4n.device.' + r.key); } var dd = diff(sort, r.cur, r.prev); return { key: r.key, label: label, kind: L.kind, raw: r, sub: L.part === 'tree' && r.kind === 'folder' ? t('ga4.folder_pages', { n: f.num(r.pages) }) : '', isNew: !r.prev && (val(sort, r.cur) || 0) > 0, cells: cols.map(function (m) { return { m: m, text: show(m, val(m, r.cur)) }; }), delta: dd ? dd.v : null, unit: dd ? dd.unit : '%', share: share && tot ? 100 * (val(sort, r.cur) || 0) / tot : null }; }) }; }, // de verdeling ernaast: balkjes van de grootste 8 (op het hoofdgetal), klikbaar side: function (data, head) { var S = this.L.side, m = this.prim; if (data === undefined) { return { loading: true, rows: [] }; } if (S.part === 'referrers') { return this.refs(data || []); } // de rechterkaart volgt de gekozen tegel, net als de lijn (Arjen 2026-11-03). Een verhouding (betrokkenheid, // weergaven per sessie, duur, conversieratio) is geen aandeel: dan staafjes met de waarde per regel. m = S.metric || (this.metric && DAILY.indexOf(this.metric) > -1 ? this.metric : m); var viz = S.viz; if (viz === 'rate' && m !== 'key_events') { viz = 'donut'; } if (viz && RATIO.indexOf(m) > -1 && viz !== 'lines') { viz = 'ratio'; } if (viz && this.viz[viz]) { var out = this.viz[viz].call(this, data, S, m, head); out.viz = viz; out.title = t(viz === 'ratio' || (viz !== S.viz) ? 'ga4.side.by.' + (S.by || 'row') : S.title, { m: this.label(m) }); return out; } if (RATIO.indexOf(m) > -1) { var o2 = this.viz.ratio.call(this, data, S, m, head); o2.viz = 'ratio'; o2.title = t(S.title || 'ga4.b.' + (S.part === 'audience' ? 'audience.' + S.dim : S.part), { m: this.label(m) }); return o2; } var rows = ((S.part === 'audience' ? (data && data.rows) : data) || []).filter(function (r) { return r.cur && (r.cur[m] || 0) > 0; }); rows.sort(function (a, b) { return b.cur[m] - a.cur[m]; }); var tot = rows.reduce(function (a, r) { return a + r.cur[m]; }, 0), max = rows.length ? rows[0].cur[m] : 1; var top = rows.slice(0, 8), rest = rows.slice(8).reduce(function (a, r) { return a + r.cur[m]; }, 0); var out = top.map(function (r) { var dd = diff(m, r.cur, r.prev); return { key: r.key, label: S.kind === 'device' ? t('ga4n.device.' + r.key) : r.key, kind: S.kind, num: f.num(r.cur[m]), pct: tot ? f.pct(100 * r.cur[m] / tot, 0) : '', width: 100 * r.cur[m] / max, delta: dd ? dd.v : null, isNew: !r.prev }; }); return { rows: out, rest: rest ? { n: rows.length - 8, num: f.num(rest), pct: f.pct(100 * rest / tot, 0) } : null, title: t(S.title || 'ga4.b.' + (S.part === 'audience' ? 'audience.' + S.dim : S.part), { m: this.label(m) }) }; }, // de vormen van de rechterkaart; data = de rijen van de tabel of van het eigen deel, m = de metriek viz: { // een verhouding per regel: de grootste regels (naar sessies), met de waarde als staafje ratio: function (data, S, m) { var rows = sideRows(data).filter(function (r) { return r.cur && r.cur.sessions > 0 && val(m, r.cur) !== null; }); rows.sort(function (a, b) { return b.cur.sessions - a.cur.sessions; }); rows = rows.slice(0, 8); var max = Math.max.apply(null, rows.map(function (r) { return val(m, r.cur) || 0; }).concat([m === 'eng_rate' || m === 'conv' ? 1 : 0.0001])); return { rows: rows.map(function (r) { var v = val(m, r.cur), dd = diff(m, r.cur, r.prev); return { key: r.key, kind: S.kind === 'tree' ? r.kind : S.kind, label: sideLabel(S, r), num: show(m, v), width: 100 * (v || 0) / max, delta: dd ? dd.v : null, unit: dd ? dd.unit : '%', sub: t('ga4.side.sessions', { n: f.num(r.cur.sessions) }) }; }) }; }, // een verdeling: de grootste vijf en de rest, met een legenda (label, getal, aandeel, verschil) die doorklikt donut: function (data, S, m, head) { var rows = sideRows(data).filter(function (r) { return r.cur && (r.cur[m] || 0) > 0; }); rows.sort(function (a, b) { return b.cur[m] - a.cur[m]; }); var tot = rows.reduce(function (a, r) { return a + r.cur[m]; }, 0), top = rows.slice(0, 5), rest = rows.slice(5); var restV = rest.reduce(function (a, r) { return a + r.cur[m]; }, 0); var legend = top.map(function (r, i) { var dd = diff(m, r.cur, r.prev); return { key: r.key, kind: S.kind === 'tree' ? r.kind : S.kind, label: sideLabel(S, r), color: CAT[i], num: show(m, r.cur[m]), pct: tot ? f.pct(100 * r.cur[m] / tot, 0) : '', delta: dd ? dd.v : null, isNew: !r.prev }; }); if (restV) { legend.push({ label: t('ga4.side.other', { n: rest.length }), color: REST, num: show(m, restV), pct: f.pct(100 * restV / tot, 0) }); } return { parts: legend.map(function (x, i) { return { label: x.label, value: i < top.length ? top[i].cur[m] : restV, color: x.color }; }), // in het midden het getal van de tegel (de totalen van Google); de regels opgeteld wijken daar iets van af legend: legend, center: show(m, head && head.totals && head.totals.cur && head.totals.cur[m] !== undefined ? head.totals.cur[m] : tot), sub: this.label(m), fmt: function (v) { return show(m, v); }, rows: legend }; }, // de mix nu tegenover de vorige periode: twee gestapelde balken in aandelen, en per deel het verschil in procentpunten shift: function (data, S, m) { var rows = sideRows(data).filter(function (r) { return r.cur && (r.cur[m] || 0) > 0; }); rows.sort(function (a, b) { return b.cur[m] - a.cur[m]; }); var sum = function (k) { return rows.reduce(function (a, r) { return a + ((r[k] && r[k][m]) || 0); }, 0); }; var tc = sum('cur'), tp = sum('prev'), top = rows.slice(0, 5), rest = rows.slice(5); var restOf = function (k) { return rest.reduce(function (a, r) { return a + ((r[k] && r[k][m]) || 0); }, 0); }; var items = top.map(function (r, i) { return { key: r.key, kind: 'channel', label: r.key, color: CAT[i], now: r.cur[m], before: (r.prev && r.prev[m]) || 0 }; }); if (rest.length) { items.push({ label: t('ga4.side.other', { n: rest.length }), color: REST, now: restOf('cur'), before: restOf('prev') }); } items.forEach(function (x) { x.shareNow = tc ? 100 * x.now / tc : 0; x.shareBefore = tp ? 100 * x.before / tp : null; x.pctNow = f.pct(x.shareNow, 0); x.pctBefore = x.shareBefore === null ? '–' : f.pct(x.shareBefore, 0); x.delta = x.shareBefore === null ? null : x.shareNow - x.shareBefore; }); var parts = function (k) { return items.map(function (x) { return { label: x.label, value: x[k], color: x.color, text: f.pct(100 * x[k] / (k === 'now' ? tc : tp), 0) }; }); }; return { now: parts('now'), before: tp ? parts('before') : [], legend: items, rows: items, fmt: function (v) { return show(m, v); } }; }, // per apparaat hoe goed het werkt: betrokkenheid en sessieduur naast elkaar (niet hoeveel) quality: function (data, S) { var rows = sideRows(data).filter(function (r) { return r.cur && r.cur.sessions > 0; }); rows.sort(function (a, b) { return b.cur.sessions - a.cur.sessions; }); var maxDur = Math.max.apply(null, rows.map(function (r) { return r.cur.duration || 0; }).concat([1])); var out = rows.slice(0, 6).map(function (r) { var rate = val('eng_rate', r.cur), dr = diff('eng_rate', r.cur, r.prev), dd = diff('duration', r.cur, r.prev); return { key: r.key, kind: S.kind, label: sideLabel(S, r), rate: show('eng_rate', rate), rateW: rate || 0, rateDelta: dr ? dr.v : null, dur: show('duration', r.cur.duration), durW: 100 * (r.cur.duration || 0) / maxDur, durDelta: dd ? dd.v : null, sessions: f.num(r.cur.sessions) }; }); return { rows: out }; }, // per dag per waarde (nieuw en terugkerend) als lijnen, in de gekozen metriek; een jaar en langer per week lines: function (data, S, m) { var dates = (data && data.dates) || [], keys = Object.keys((data && data.series) || {}).slice(0, 2); var span = dates.length > 120 ? 7 : 1, idx = []; for (var i = 0; i < dates.length; i += span) { idx.push(i); } var series = keys.map(function (k, j) { var d = data.series[k]; return { label: t('ga4.side.nv.' + k) || k, color: CAT[j], strong: j === 0, values: idx.map(function (i) { var o = {}, dw = 0; SUMS.forEach(function (x) { o[x] = (d[x] || []).slice(i, i + span).reduce(function (a, v) { return a + (v || 0); }, 0); }); for (var n = i; n < i + span && n < dates.length; n++) { dw += ((d.duration || [])[n] || 0) * ((d.sessions || [])[n] || 0); } o.duration = o.sessions ? dw / o.sessions : null; return val(m, o); }) }; }); var lab = span > 1 ? function (x) { return t('ga4.range.week', { d: f.dayWeek(x) }); } : function (x) { return f.dayWeek(x); }; var step = idx.length > 31 ? (idx.length > 60 ? 8 : 14) : 7; return { chart: idx.length ? { labels: idx.map(function (i, n) { return n % step === 0 ? lab(dates[i]) : ''; }), titles: idx.map(function (i) { return lab(dates[i]); }), series: series } : null, rows: series, fmt: function (v) { return show(m, v); }, ints: RATIO.indexOf(m) < 0 && m !== 'revenue' }; }, // hangt alles aan een paar pagina's: het aandeel van de top 10 in alle sessies (de totalen van de kop) concentration: function (data, S, m, head) { var rows = sideRows(data).filter(function (r) { return r.cur && (r.cur[m] || 0) > 0; }); rows.sort(function (a, b) { return b.cur[m] - a.cur[m]; }); var all = head && head.totals && head.totals.cur ? head.totals.cur[m] : rows.reduce(function (a, r) { return a + r.cur[m]; }, 0); var top = rows.slice(0, 10), topV = top.reduce(function (a, r) { return a + r.cur[m]; }, 0); var share = all ? 100 * topV / all : 0; var parts = top.map(function (r, i) { return { label: r.key, value: r.cur[m], color: i % 2 ? '#3b82f6' : BLUE }; }); if (all > topV) { parts.push({ label: t('ga4.side.other', { n: Math.max(rows.length - 10, 0) }), value: all - topV, color: REST }); } return { big: f.pct(share, 0), text: t('ga4.side.top10_text', { n: top.length, m: this.label(m).toLowerCase() }), parts: parts, fmt: function (v) { return show(m, v); }, rows: top.slice(0, 5).map(function (r) { return { key: r.key, kind: 'page', label: r.key, pct: all ? f.pct(100 * r.cur[m] / all, 1) : '' }; }) }; }, // waar het converteert: de conversieratio (key events per sessie), alleen regels met genoeg sessies rate: function (data, S) { var rows = sideRows(data).filter(function (r) { return r.cur && r.cur.sessions > 0; }); var tot = rows.reduce(function (a, r) { return a + r.cur.sessions; }, 0), min = Math.max(30, tot / 100); rows = rows.filter(function (r) { return r.cur.sessions >= min && val('conv', r.cur) !== null; }); rows.sort(function (a, b) { return val('conv', b.cur) - val('conv', a.cur) || b.cur.sessions - a.cur.sessions; }); var max = rows.length ? val('conv', rows[0].cur) || 1 : 1; return { rows: rows.slice(0, 8).map(function (r) { var c = val('conv', r.cur), dd = diff('conv', r.cur, r.prev); return { key: r.key, kind: S.kind, label: r.key, num: show('conv', c), width: max ? 100 * c / max : 0, delta: dd ? dd.v : null, unit: 'pt', sub: t('ga4.side.rate_sub', { k: f.num(r.cur.key_events || 0), s: f.num(r.cur.sessions) }) }; }), min: f.num(Math.round(min)) }; } }, // de pagina's op de gekozen bron die naar je linken (pageReferrer), met waar ze binnenkwamen; alleen van die site // zelf: GA4 legt de verwijzer per paginaweergave vast, dus er hangen ook verwijzers van andere sites aan refs: function (data) { var m = this.prim; var bare = function (x) { return String(x || '').toLowerCase().replace(/^(www|m|l|lm|mobile)\./, ''); }; var src = bare(String(this.q.source || '').split(' / ')[0]); var rows = data.filter(function (r) { var hst = bare(r.host); return hst === src || hst.slice(-src.length - 1) === '.' + src || src.slice(-hst.length - 1) === '.' + hst; }) .filter(function (r) { return (r.cur[m] || r.cur.views || 0) > 0; }); rows.sort(function (a, b) { return (b.cur[m] || 0) - (a.cur[m] || 0) || (b.cur.views || 0) - (a.cur.views || 0); }); var max = rows.length ? Math.max.apply(null, rows.map(function (r) { return r.cur[m] || r.cur.views || 0; })) : 1; var site = ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, ''); return { title: t('ga4.b.referrers', { s: src }), rows: rows.slice(0, 12).map(function (r) { var dd = diff(m, r.cur, r.prev), n = r.cur[m] || 0; // meestal geeft de browser alleen het domein door (niet de pagina): dan naast 'bezoek' ook Google, dat de pagina op // die site zoekt die naar je linkt var path = /^https?:\/\/[^/]+\/./.test(r.ref); return { key: r.page, label: r.ref.replace(/^https?:\/\/(www\.)?/, ''), kind: 'page', href: r.ref, hrefTitle: path ? t('ga4.open_ref') : t('ga4.open_site', { host: r.host }), google: path ? null : 'https://www.google.com/search?q=' + encodeURIComponent('site:' + r.host + ' "' + site + '"'), googleTitle: t('ga4.find_ref', { host: r.host }), sub: '→ ' + r.page, num: f.num(n || r.cur.views), pct: n ? '' : t('ga4.views_short'), width: 100 * (n || r.cur.views || 0) / max, delta: dd ? dd.v : null, isNew: !r.prev }; }), rest: null }; } }; $scope.$on('$routeUpdate', function () { c.readUrl(); c.nav(); if (c.snapshot() !== c.loaded) { c.load(); } }); c.readUrl(); c.init(); }]); // Analytics (GA4), nieuwe pagina: /analytics4 (Overzicht), /analytics4/kanalen, /paginas, /publiek, /conversies. // Proef, alleen rol 1-2 van de eigen reseller (Arjen 2026-11-01: "bouw ze dan beiden maar ff alleen voor mijn rol"): het hoofdgetal is te // wisselen tussen sessies (standaard, zoals nu) en betrokken sessies (het idee in docs/todo-analytics-betrokken-sessies.md). // Eén filterbalk voor alle tabs, in de URL (channel, source, page, contains, country, device, days, compare); elke rij // is een filter; een landingspagina opent een paneel (?open=). Server: API v1 analytics.view (rest/include/analytics/), // per deel één verzoek, tegelijk. Ontwerp: canvas "Analytics ontwerp" (https://claude.ai/artifact/QzgVjiDxSsuYwDdrV66JGV). // de laatst opgehaalde delen per verzoek: meteen tonen, op de achtergrond vernieuwen var ga4nState = { site: null, cache: {} }; app.controller('ga4new', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt) { // proef, alleen Arjen (docs/proef/README.md) if (!$rootScope.user || !$rootScope.user.owner) { $location.path('/ga4'); return; } var BASE = '/proef/analytics-v1'; var TABS = { '': 'overview', kanalen: 'channels', paginas: 'pages', publiek: 'audience', conversies: 'conversions' }; var SLUG = { overview: '', channels: '/kanalen', pages: '/paginas', audience: '/publiek', conversions: '/conversies' }; // welke delen elke tab ophaalt (tegelijk) var PARTS = { overview: ['head', 'channels', 'landing', 'notable'], channels: ['head', 'channels', 'sources', 'devices', 'landing'], pages: ['pages'], audience: ['head', 'audience'], conversions: ['head', 'events', 'channels', 'landing', 'items'] }; var FILTERS = ['channel', 'source', 'page', 'contains', 'country', 'device']; var PRIM_KEY = 'mt.ga4n.prim'; var AUDIENCE = ['country', 'deviceCategory', 'browser', 'newVsReturning', 'language', 'city']; var BLUE = '#2563eb', GREY = '#9ca3af'; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (ga4nState.site !== h.siteId()) { ga4nState = { site: h.siteId(), cache: {} }; } var prim = 'sessions'; try { prim = window.localStorage.getItem(PRIM_KEY) === 'engaged' ? 'engaged' : 'sessions'; } catch (e) { prim = 'sessions'; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; var dur = function (s) { if (none(s)) { return '–'; } s = Math.round(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; // verschil in procent, of null (nieuw of niets te vergelijken) var ch = function (r, m) { return r.prev ? f.change(r.cur[m], r.prev[m]) : null; }; var c = $scope.c = { f: f, dur: dur, tab: TABS[$routeParams.tab || ''] || 'overview', prim: prim, tabs: [], q: {}, d: {}, st: {}, v: {}, chartMetric: prim, sort: { key: prim, desc: true }, pageMode: 'landing', dim: 'country', dims: AUDIENCE, open: null, panel: {}, containsInput: '', channelOptions: [], devices: ['desktop', 'mobile', 'tablet'], dayOpts: [], compareOpts: [], init: function () { var self = this; this.tabs = ['overview', 'channels', 'pages', 'audience', 'conversions'].map(function (k) { return { key: k, label: '', href: BASE + SLUG[k] + self.qs() }; }); // labels pas als de vertalingen er zijn (instant geeft anders de sleutel) $translate.onReady(function () { self.tabs = self.tabs.map(function (x) { return angular.extend({}, x, { label: t('ga4n.tab.' + x.key) }); }); self.dayOpts = [7, 28, 90].map(function (n) { return { v: n, l: t('ga4n.days', { n: n }) }; }); self.compareOpts = ['prev', 'year'].map(function (k) { return { v: k, l: t('ga4n.prev.' + k) }; }); }); this.load(); }, // ---- de URL: filters, periode, weergave ---- readUrl: function () { var s = $location.search(), q = {}; FILTERS.forEach(function (k) { if (s[k]) { q[k] = String(s[k]); } }); this.q = q; this.days = [7, 28, 90].indexOf(+s.days) > -1 ? +s.days : 28; this.compare = s.compare === 'year' ? 'year' : 'prev'; this.pageMode = s.mode === 'all' ? 'all' : 'landing'; this.dim = AUDIENCE.indexOf(s.dim) > -1 ? s.dim : 'country'; this.open = s.open ? String(s.open) : null; this.containsInput = q.contains || ''; }, // de filters en de periode als querystring, voor de tabs (filters blijven staan als je van tab wisselt) qs: function () { var s = $location.search(), parts = []; FILTERS.concat(['days', 'compare']).forEach(function (k) { if (s[k]) { parts.push(k + '=' + encodeURIComponent(s[k])); } }); return parts.length ? '?' + parts.join('&') : ''; }, set: function (k, v) { $location.search(k, v === null || v === undefined || v === '' ? null : v); }, setFilter: function (k, v) { this.set(k, this.q[k] === v ? null : v); if (k === 'page') { this.set('open', null); } }, clearFilters: function () { var self = this; FILTERS.forEach(function (k) { self.set(k, null); }); }, applyContains: function () { this.set('contains', (this.containsInput || '').trim() || null); }, setPrim: function (p) { if (p === this.prim) { return; } this.prim = p; this.chartMetric = p; this.sort = { key: p, desc: true }; try { window.localStorage.setItem(PRIM_KEY, p); } catch (e) { /* privévenster */ } this.load(); }, setChart: function (m) { this.chartMetric = m; this.refresh(); }, setMode: function (m) { this.set('mode', m === 'all' ? 'all' : null); }, setDim: function (m) { this.set('dim', m === 'country' ? null : m); }, // ---- ophalen: per deel één verzoek, tegelijk; het vorige antwoord voor hetzelfde verzoek staat er meteen ---- body: function (part, filters) { var b = { site_id: h.siteId(), part: part, days: this.days, compare: this.compare, prim: this.prim, filters: filters || this.q }; if (part === 'audience') { b.dim = this.dim; } if (part === 'pages' && this.pageMode === 'landing') { b.part = 'landing'; } if (part === 'landing' && this.tab === 'overview') { b.limit = 500; } return b; }, fetch: function (part, body, put) { var self = this, key = JSON.stringify(body); if (ga4nState.cache[key]) { put(ga4nState.cache[key]); } $http.post('/rest/v1/analytics.view', body).then(function (res) { ga4nState.cache[key] = res.data.data; put(res.data.data); self.refresh(); }, function (res) { put({ state: 'error', message: res.status === 403 ? t('ga4n.no_access') : '' }); self.refresh(); }); }, // wat bepaalt wat er opgehaald wordt; bij een andere URL alleen opnieuw ophalen als dit verandert snapshot: function () { return JSON.stringify([this.q, this.days, this.compare, this.pageMode, this.dim]); }, load: function () { var self = this, gen = (this.gen = (this.gen || 0) + 1); this.readUrl(); this.loaded = this.snapshot(); this.d = {}; PARTS[this.tab].forEach(function (part) { self.fetch(part, self.body(part), function (data) { if (gen === self.gen) { self.d[part] = data; } }); }); // de kanalen voor het keuzemenu in de filterbalk (zonder kanaalfilter, uit de cache als die er is) if (PARTS[this.tab].indexOf('channels') < 0) { this.fetch('channels', this.body('channels'), function (data) { if (gen === self.gen) { self.d.chOpts = data; } }); } this.loadPanel(); this.refresh(); }, // paneel van één landingspagina: de delen met die pagina als extra filter, plus Search Console loadPanel: function () { var self = this, page = this.open; this.panel = {}; if (!page) { return; } var fl = angular.extend({}, this.q, { page: page }); delete fl.contains; ['head', 'channels', 'devices', 'gsc'].forEach(function (part) { self.fetch(part, self.body(part, fl), function (data) { if (self.open === page) { self.panel[part] = data; } }); }); }, openPage: function (path) { if (this.tab !== 'pages') { $location.path(BASE + '/paginas').search(angular.extend({}, $location.search(), { open: path })); return; } this.set('open', this.open === path ? null : path); }, closePanel: function () { this.set('open', null); }, gsciHref: function (path) { return '/search-insights/verkennen?page=' + encodeURIComponent(this.pageUrl(path)); }, pageUrl: function (path) { return 'https://' + ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/\/$/, '') + path; }, // ---- wat de pagina toont, één keer berekend ---- refresh: function () { var self = this, d = this.d, v = {}; var head = d.head && d.head.head; v.state = this.stateOf(); v.measures = (d.head && d.head.measures) || {}; v.period = (d.head || d.pages || d.audience || {}).period || null; if (v.period) { v.periodText = t('ga4n.period.' + v.period.compare, { from: f.day(v.period.cur.from), to: f.day(v.period.cur.to), pfrom: f.day(v.period.prev.from), pto: f.day(v.period.prev.to) }); } v.kpis = head ? this.kpis(head.totals, v.measures) : null; v.chart = head ? this.chart(head.daily, this.chartMetric) : null; v.channels = this.rows(d.channels && d.channels.channels); v.sources = this.rows(d.sources && d.sources.sources); v.devices = this.rows(d.devices && d.devices.devices); v.landing = this.rows(d.landing && d.landing.landing); v.pages = this.rows(d.pages && (d.pages.landing || d.pages.pages)); v.audience = this.rows(d.audience && d.audience.audience && d.audience.audience.rows); v.events = d.events && d.events.events ? d.events.events : null; v.items = d.items && d.items.items ? d.items.items : null; v.notable = d.notable && d.notable.notable ? d.notable.notable.map(function (n) { return self.notableText(n); }) : null; var opts = (d.channels && d.channels.channels) || (d.chOpts && d.chOpts.channels) || []; this.channelOptions = opts.map(function (r) { return r.key; }); if (this.q.channel && this.channelOptions.indexOf(this.q.channel) < 0) { this.channelOptions.unshift(this.q.channel); } // aandeel van het gekozen kanaal (Kanalen) if (this.q.channel && d.channels && d.channels.channels) { var all = { sessions: 0, engaged: 0 }, mine = null; d.channels.channels.forEach(function (r) { all.sessions += r.cur.sessions; all.engaged += r.cur.engaged; if (r.key === self.q.channel) { mine = r; } }); v.share = mine ? { sessions: all.sessions ? 100 * mine.cur.sessions / all.sessions : null, engaged: all.engaged ? 100 * mine.cur.engaged / all.engaged : null } : null; } if (v.channels) { var tot = v.channels.reduce(function (a, r) { return a + (r.cur[self.prim] || 0); }, 0); v.channels.forEach(function (r) { r.share = tot ? 100 * (r.cur[self.prim] || 0) / tot : 0; }); } this.sortRows(v); v.panel = this.panelView(); v.filterList = FILTERS.filter(function (k) { return self.q[k]; }).map(function (k) { return { key: k, label: t('ga4n.filter.' + k), value: self.filterValue(k, self.q[k]) }; }); this.v = v; }, stateOf: function () { var all = Object.keys(this.d).map(function (k) { return this.d[k]; }, this); if (all.some(function (x) { return x && x.state === 'connect'; })) { return 'connect'; } var err = all.filter(function (x) { return x && x.state === 'error'; })[0]; return err ? 'error' : 'ok'; }, filterValue: function (k, val) { if (k === 'device') { return t('ga4n.device.' + val); } return val; }, kpis: function (tot, measures) { var cur = tot.cur, prev = tot.prev, self = this; var tile = function (key, label, value, delta, unit, pick) { return { key: key, label: label, value: value, delta: delta, unit: unit || '%', pick: pick }; }; var list = [ tile('sessions', t('ga4n.m.sessions'), f.num(cur.sessions), f.change(cur.sessions, prev.sessions), '%', 'sessions'), tile('engaged', t('ga4n.m.engaged'), f.num(cur.engaged), f.change(cur.engaged, prev.engaged), '%', 'engaged'), tile('eng_rate', t('ga4n.m.eng_rate'), f.pct(cur.eng_rate), none(cur.eng_rate) || none(prev.eng_rate) ? null : cur.eng_rate - prev.eng_rate, 'pt'), tile('duration', t('ga4n.m.duration'), dur(cur.duration), none(prev.duration) || !prev.duration ? null : f.change(cur.duration, prev.duration)), tile('users', t('ga4n.m.users'), f.num(cur.users), f.change(cur.users, prev.users), '%', 'users') ]; if (measures.key_events) { list.push(tile('key_events', t('ga4n.m.key_events'), f.num(cur.key_events), f.change(cur.key_events, prev.key_events), '%', 'key_events')); } if (measures.revenue) { list.push(tile('revenue', t('ga4n.m.revenue'), f.euro(cur.revenue), f.change(cur.revenue, prev.revenue))); } // het hoofdgetal eerst list.sort(function (a, b) { return (b.key === self.prim) - (a.key === self.prim); }); return list; }, chart: function (daily, m) { var n = daily.dates.length, step = n > 31 ? 14 : 7; return { labels: daily.dates.map(function (d, i) { return i % step === 0 ? f.dayWeek(d) : ''; }), titles: daily.dates.map(function (d, i) { return f.dayWeek(d) + ' vs ' + f.dayWeek(daily.prev_dates[i]); }), series: [ { label: t('ga4n.m.' + m) + ' · ' + t('ga4n.now'), color: BLUE, values: daily.cur[m], strong: true }, { label: t('ga4n.prev.' + this.compare), color: GREY, values: daily.prev[m], dash: true } ] }; }, // rijen met hun verschillen (één keer berekend, niet in de ng-repeat) rows: function (list) { if (!list) { return null; } return list.map(function (r) { return angular.extend({}, r, { dS: ch(r, 'sessions'), dE: ch(r, 'engaged'), dV: ch(r, 'views'), dK: ch(r, 'key_events'), dR: ch(r, 'revenue'), dRate: r.prev && !none(r.cur.eng_rate) && !none(r.prev.eng_rate) ? r.cur.eng_rate - r.prev.eng_rate : null, isNew: !r.prev && (r.cur.sessions || 0) > 0 }); }); }, sortRows: function (v) { var s = this.sort, get = function (r) { return s.key === 'key' ? r.key : (r.cur[s.key] === null || r.cur[s.key] === undefined ? -1 : r.cur[s.key]); }; ['pages', 'audience'].forEach(function (k) { if (!v[k]) { return; } v[k].sort(function (a, b) { var x = get(a), y = get(b); var r = typeof x === 'string' ? x.localeCompare(y) : x - y; return s.desc ? -r : r; }); }); }, notableText: function (n) { var p = { key: n.key, now: f.num(n.now), before: f.num(n.before), pct: f.num(Math.abs(n.pct || 0)), m: t('ga4n.m.' + this.prim).toLowerCase(), sessions: f.num(n.sessions), rate: f.pct(n.rate), diff: f.num(Math.abs(n.diff || 0), 1) }; var tone = { channel_up: 'up', channel_new: 'up', page_up: 'up', page_new: 'up', engagement_up: 'up', channel_down: 'down', page_down: 'down', engagement_down: 'down', low_engagement: 'warn' }[n.kind]; var act = null; if (n.kind.indexOf('channel') === 0 || n.kind === 'low_engagement') { act = { filter: 'channel', value: n.key }; } if (n.kind.indexOf('page') === 0) { act = { page: n.key }; } return { tone: tone, text: t('ga4n.note.' + n.kind, p), act: act }; }, noteClick: function (n) { if (!n.act) { return; } if (n.act.page) { this.openPage(n.act.page); } else { this.setFilter(n.act.filter, n.act.value); } }, panelView: function () { if (!this.open) { return null; } var p = this.panel, head = p.head && p.head.head; return { path: this.open, kpis: head ? [ { label: t('ga4n.m.sessions'), value: f.num(head.totals.cur.sessions), delta: f.change(head.totals.cur.sessions, head.totals.prev.sessions), unit: '%' }, { label: t('ga4n.m.engaged'), value: f.num(head.totals.cur.engaged), delta: f.change(head.totals.cur.engaged, head.totals.prev.engaged), unit: '%' }, { label: t('ga4n.m.eng_rate'), value: f.pct(head.totals.cur.eng_rate), delta: none(head.totals.cur.eng_rate) || none(head.totals.prev.eng_rate) ? null : head.totals.cur.eng_rate - head.totals.prev.eng_rate, unit: 'pt' }, { label: t('ga4n.m.duration'), value: dur(head.totals.cur.duration), delta: null, unit: '%' } ] : null, chart: head ? this.chart(head.daily, this.prim) : null, channels: this.rows(p.channels && p.channels.channels), devices: this.rows(p.devices && p.devices.devices), gsc: p.gsc ? (p.gsc.gsc || { state: p.gsc.state }) : null, error: [p.head, p.channels].some(function (x) { return x && x.state === 'error'; }) }; } }; $scope.$watch('c.sort', function (n, o) { if (n !== o) { c.sortRows(c.v); } }, true); // filters en weergave in de URL: zonder de route te herladen (reloadOnSearch false), opnieuw ophalen $scope.$on('$routeUpdate', function () { var open = c.open, mode = c.loaded ? JSON.parse(c.loaded)[3] : c.pageMode; c.readUrl(); c.tabs.forEach(function (x) { x.href = BASE + SLUG[x.key] + c.qs(); }); if (c.snapshot() !== c.loaded) { if (c.pageMode !== mode) { c.sort = { key: c.pageMode === 'all' ? 'views' : c.prim, desc: true }; } c.load(); } else if (c.open !== open) { c.loadPanel(); c.refresh(); } }); c.readUrl(); c.sort = { key: c.pageMode === 'all' && c.tab === 'pages' ? 'views' : c.prim, desc: true }; c.init(); }]); // Analytics (GA4), versie 2 van de nieuwe pagina: /ga4 (Overzicht), /ga4/herkomst, /paginas, /publiek, /conversies. // Naast /analytics4 (v1) om te vergelijken (Arjen 2026-11-02: "maak een kopie controller zodat ik kan vergelijken, maar // dan beter"). Proef, alleen rol 1-2 van de eigen reseller. Ontwerp: docs/proef/analytics-v2.md. // De regel: alles waar je op klikt is een filter, en de pagina gaat dan over dat ding (de focus): een kanaal, een bron, // of één pagina. Een pagina aanklikken opent het Overzicht van die pagina (pagina-rapport). Lijsten tonen er 8 en // "meer", nooit een schuifbalk in een kaart. Server: API v1 analytics.view, per deel één verzoek, tegelijk. // de laatst opgehaalde delen per verzoek: meteen tonen, op de achtergrond vernieuwen var ga4v2State = { site: null, cache: {} }; app.controller('ga4v2', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt) { // proef, alleen Arjen (docs/proef/README.md) if (!$rootScope.user || !$rootScope.user.owner) { $location.path('/ga4'); return; } var BASE = '/proef/analytics-v2'; var TABS = { '': 'overview', herkomst: 'sources', paginas: 'pages', publiek: 'audience', conversies: 'conversions' }; var SLUG = { overview: '', sources: '/herkomst', pages: '/paginas', audience: '/publiek', conversions: '/conversies' }; var FILTERS = ['channel', 'source', 'page', 'path', 'contains', 'country', 'device']; var PRIM_KEY = 'mt.ga4n.prim'; var AUDIENCE = ['country', 'deviceCategory', 'browser', 'newVsReturning', 'language', 'city']; var SEGS = ['all', 'up', 'down', 'new', 'attention', 'conv']; var BLUE = '#2563eb', GREY = '#9ca3af'; // vaste kleur per kanaal (de herkomstbalk per pagina en de kanaalkaarten), met een legenda erbij var CH = { 'Organic Search': '#0f766e', 'Direct': '#64748b', 'Referral': '#7c3aed', 'Organic Social': '#db2777', 'Paid Search': '#ea580c', 'Paid Social': '#e11d48', 'Email': '#ca8a04', 'Organic Video': '#0891b2', 'Organic Shopping': '#059669', 'Paid Shopping': '#f97316', 'Display': '#a16207', 'Affiliates': '#65a30d', 'Cross-network': '#9333ea', 'AI Assistant': '#0ea5e9', 'Unassigned': '#d1d5db' }; var chColor = function (k) { return CH[k] || '#94a3b8'; }; // kanalen en bronnen zonder verwijzende pagina: zoekmachines (die laten we weg), direct, advertenties in Google var NOREF_CH = ['Organic Search', 'Direct', 'Paid Search', 'Organic Shopping', 'Paid Shopping']; var NOREF_SRC = /(\/ (organic|cpc|ppc)$|^\(direct\))/; // AI-assistenten (komen in GA4 binnen als Referral): een label erbij var AI = /(chatgpt|openai|perplexity|gemini\.google|bard\.google|copilot|claude\.ai|you\.com|mistral|deepseek|meta\.ai|grok)/i; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (ga4v2State.site !== h.siteId()) { ga4v2State = { site: h.siteId(), cache: {} }; } var prim = 'sessions'; try { prim = window.localStorage.getItem(PRIM_KEY) === 'engaged' ? 'engaged' : 'sessions'; } catch (e) { prim = 'sessions'; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; var dur = function (s) { if (none(s)) { return '–'; } s = Math.round(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; var ch = function (r, m) { return r.prev ? f.change(r.cur[m], r.prev[m]) : null; }; var bare = function (p) { return String(p || '').replace(/[?#].*$/, ''); }; var c = $scope.c = { f: f, dur: dur, chColor: chColor, tab: TABS[$routeParams.tab || ''] || 'overview', prim: prim, tabs: [], q: {}, d: {}, v: {}, chartMetric: prim, sort: { key: prim, desc: true }, pageMode: 'landing', seg: 'all', dim: 'country', dims: AUDIENCE, segs: SEGS, more: {}, containsInput: '', channelOptions: [], devices: ['desktop', 'mobile', 'tablet'], dayOpts: [], compareOpts: [], init: function () { var self = this; this.tabs = ['overview', 'sources', 'pages', 'audience', 'conversions'].map(function (k) { return { key: k, label: '', href: BASE + SLUG[k] + self.qs() }; }); // labels pas als de vertalingen er zijn (instant geeft anders de sleutel) $translate.onReady(function () { self.tabs = self.tabs.map(function (x) { return angular.extend({}, x, { label: t('ga4v.tab.' + x.key) }); }); self.dayOpts = [7, 28, 90].map(function (n) { return { v: n, l: t('ga4n.days', { n: n }) }; }); self.compareOpts = ['prev', 'year'].map(function (k) { return { v: k, l: t('ga4n.prev.' + k) }; }); self.refresh(); }); this.load(); }, // ---- de URL: filters, periode, weergave ---- readUrl: function () { var s = $location.search(), q = {}; FILTERS.forEach(function (k) { if (s[k]) { q[k] = String(s[k]); } }); this.q = q; this.days = [7, 28, 90].indexOf(+s.days) > -1 ? +s.days : 28; this.compare = s.compare === 'year' ? 'year' : 'prev'; this.pageMode = s.mode === 'all' ? 'all' : 'landing'; this.seg = SEGS.indexOf(s.seg) > -1 ? s.seg : 'all'; this.dim = AUDIENCE.indexOf(s.dim) > -1 ? s.dim : 'country'; this.containsInput = q.contains || ''; }, // waar de pagina over gaat: één pagina, een bron, een kanaal of de hele site focus: function () { var q = this.q; return q.page ? 'page' : q.path ? 'path' : q.source ? 'source' : q.channel ? 'channel' : 'site'; }, qs: function () { var s = $location.search(), parts = []; FILTERS.concat(['days', 'compare']).forEach(function (k) { if (s[k]) { parts.push(k + '=' + encodeURIComponent(s[k])); } }); return parts.length ? '?' + parts.join('&') : ''; }, set: function (k, v) { $location.search(k, v === null || v === undefined || v === '' ? null : v); }, // het keuzemenu: ng-model staat er al op, dus geen aan/uit zoals bij een klik pickChannel: function (v) { this.set('channel', v || null); this.set('source', null); }, setFilter: function (k, v) { var off = this.q[k] === v; this.set(k, off ? null : v); // een ander kanaal: de bron hoort bij het oude kanaal if (k === 'channel' && !off) { this.set('source', null); } }, // een pagina aanklikken: het Overzicht van die pagina (landingspagina of bekeken pagina) openPage: function (path, kind) { var s = angular.extend({}, $location.search()); delete s.page; delete s.path; delete s.contains; delete s.seg; delete s.mode; s[kind === 'path' ? 'path' : 'page'] = path; $location.path(BASE).search(s); }, clearFilters: function () { var self = this; FILTERS.forEach(function (k) { self.set(k, null); }); }, applyContains: function () { this.set('contains', (this.containsInput || '').trim() || null); }, setPrim: function (p) { if (p === this.prim) { return; } this.prim = p; this.chartMetric = p; this.sort = { key: p, desc: true }; try { window.localStorage.setItem(PRIM_KEY, p); } catch (e) { /* privévenster */ } this.load(); }, setChart: function (m) { this.chartMetric = m; this.refresh(); }, setMode: function (m) { this.set('mode', m === 'all' ? 'all' : null); this.set('seg', null); }, setSeg: function (s) { this.set('seg', s === 'all' ? null : s); }, setDim: function (m) { this.set('dim', m === 'country' ? null : m); }, showMore: function (key) { this.more[key] = (this.more[key] || 8) < 25 ? 25 : (this.more[key] || 8) + 50; this.refresh(); }, act: function (r) { if (!r || !r.act) { return; } if (r.act === 'page' || r.act === 'path') { this.openPage(r.val, r.act); } else { this.setFilter(r.act, r.val); } }, // ---- ophalen: per deel één verzoek, tegelijk ---- // verwijzende pagina's alleen waar ze kunnen bestaan (Referral, social, AI, e-mail, een pagina …) refsUseful: function () { return NOREF_CH.indexOf(this.q.channel) < 0 && !NOREF_SRC.test(this.q.source || ''); }, // welke delen deze tab met deze focus nodig heeft: [{slot, part, limit?, filters?}] slots: function () { var S = [], fo = this.focus(), tab = this.tab; var add = function (slot, part, extra) { S.push(angular.extend({ slot: slot, part: part }, extra || {})); }; add('head', 'head'); if (tab === 'overview') { add('channels', 'channels'); add('landing', 'landing', { limit: 500 }); add('sources', 'sources'); if (this.refsUseful()) { add('referrers', 'referrers'); } if (fo === 'page') { add('next', 'pages'); } } if (tab === 'sources') { add('channels', 'channels'); add('sources', 'sources'); if (this.refsUseful()) { add('referrers', 'referrers'); } add('devices', 'devices'); } if (tab === 'pages') { add('pages', this.pageMode === 'all' ? 'pages' : 'landing', { limit: 500 }); if (this.pageMode === 'landing') { add('mix', 'mix'); } } if (tab === 'audience') { add('audience', 'audience'); } if (tab === 'conversions') { add('events', 'events'); add('channels', 'channels'); add('landing', 'landing', { limit: 500 }); add('items', 'items'); } return S; }, body: function (s) { var b = { site_id: h.siteId(), part: s.part, days: this.days, compare: this.compare, prim: this.prim, filters: this.q }; if (s.part === 'audience') { b.dim = this.dim; } if (s.limit) { b.limit = s.limit; } return b; }, fetch: function (body, put) { var self = this, key = JSON.stringify(body); if (ga4v2State.cache[key]) { put(ga4v2State.cache[key]); } $http.post('/rest/v1/analytics.view', body).then(function (res) { ga4v2State.cache[key] = res.data.data; put(res.data.data); self.refresh(); }, function (res) { put({ state: 'error', message: res.status === 403 ? t('ga4n.no_access') : '' }); self.refresh(); }); }, snapshot: function () { return JSON.stringify([this.q, this.days, this.compare, this.pageMode, this.dim, this.tab]); }, load: function () { var self = this, gen = (this.gen = (this.gen || 0) + 1), slots; this.readUrl(); this.loaded = this.snapshot(); this.d = {}; this.more = {}; slots = this.slots(); slots.forEach(function (s) { self.fetch(self.body(s), function (data) { if (gen === self.gen) { self.d[s.slot] = data; } }); }); // de kanalen voor het keuzemenu (zonder kanaalfilter; uit de cache als die er is) if (!slots.some(function (s) { return s.slot === 'channels'; })) { this.fetch(this.body({ part: 'channels' }), function (data) { if (gen === self.gen) { self.d.chOpts = data; } }); } this.refresh(); }, gsciHref: function (path) { return '/search-insights/verkennen?page=' + encodeURIComponent(this.pageUrl(path)); }, pageUrl: function (path) { return 'https://' + ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/\/$/, '') + path; }, // ---- wat de pagina toont, één keer berekend ---- refresh: function () { var self = this, d = this.d, v = {}, fo = this.focus(); // null = nog niet binnen (kaart laadt); een deel met een fout geeft een lege lijst var part = function (slot) { var x = d[slot]; return x ? (x.state === 'ok' ? x[x.part] : (slot === 'head' || slot === 'mix' ? null : [])) : null; }; var head = part('head'); v.focus = fo; v.state = this.stateOf(); v.measures = (d.head && d.head.measures) || {}; v.period = (d.head || {}).period || null; if (v.period) { v.periodText = t('ga4n.period.' + v.period.compare, { from: f.day(v.period.cur.from), to: f.day(v.period.cur.to), pfrom: f.day(v.period.prev.from), pto: f.day(v.period.prev.to) }); } v.avgRate = head ? head.totals.cur.eng_rate : null; v.kpis = head ? this.kpis(head.totals, v.measures) : null; v.chart = head ? this.chart(head.daily, this.chartMetric) : null; v.channels = this.rows(part('channels')); v.sources = this.rows(part('sources')); v.devices = this.rows(part('devices')); v.landing = this.rows(part('landing')); v.referrers = this.refsUseful() ? part('referrers') : undefined; v.next = this.rows(part('next')); v.audience = this.rows(d.audience ? (part('audience') || {}).rows || [] : null); v.events = part('events'); v.items = part('items'); var opts = part('channels') || part('chOpts') || []; this.channelOptions = opts.map(function (r) { return r.key; }); if (this.q.channel && this.channelOptions.indexOf(this.q.channel) < 0) { this.channelOptions.unshift(this.q.channel); } if (v.channels) { var tot = v.channels.reduce(function (a, r) { return a + (r.cur[self.prim] || 0); }, 0); v.channels.forEach(function (r) { r.share = tot ? 100 * (r.cur[self.prim] || 0) / tot : 0; r.color = chColor(r.key); }); // aandeel van het gekozen kanaal if (this.q.channel) { var all = { sessions: 0, engaged: 0 }, mine = null; v.channels.forEach(function (r) { all.sessions += r.cur.sessions; all.engaged += r.cur.engaged; if (r.key === self.q.channel) { mine = r; } }); v.share = mine ? { sessions: all.sessions ? 100 * mine.cur.sessions / all.sessions : null, engaged: all.engaged ? 100 * mine.cur.engaged / all.engaged : null } : null; } } v.changed = this.changed(v, head); v.cards = this.cards(v); v.pagesView = this.tab === 'pages' ? this.pagesView(part('pages'), part('mix'), v.avgRate) : null; v.channelLegend = v.pagesView ? v.pagesView.legend : null; if (v.audience) { this.sortList(v.audience); } v.filterList = FILTERS.filter(function (k) { return self.q[k]; }).map(function (k) { return { key: k, label: t('ga4v.filter.' + k), value: self.filterValue(k, self.q[k]) }; }); v.focusPath = this.q.page || this.q.path || null; this.v = v; }, stateOf: function () { var d = this.d, all = ['head', 'channels', 'pages', 'audience', 'events'].map(function (k) { return d[k]; }); if (all.some(function (x) { return x && x.state === 'connect'; })) { return 'connect'; } return all.some(function (x) { return x && x.state === 'error'; }) ? 'error' : 'ok'; }, filterValue: function (k, val) { return k === 'device' ? t('ga4n.device.' + val) : val; }, kpis: function (tot, measures) { var cur = tot.cur, prev = tot.prev, self = this; var tile = function (key, value, delta, unit, pick) { return { key: key, label: t('ga4n.m.' + key), value: value, delta: delta, unit: unit || '%', pick: pick }; }; var list = [ tile('sessions', f.num(cur.sessions), f.change(cur.sessions, prev.sessions), '%', 'sessions'), tile('engaged', f.num(cur.engaged), f.change(cur.engaged, prev.engaged), '%', 'engaged'), tile('eng_rate', f.pct(cur.eng_rate), none(cur.eng_rate) || none(prev.eng_rate) ? null : cur.eng_rate - prev.eng_rate, 'pt'), tile('duration', dur(cur.duration), none(prev.duration) || !prev.duration ? null : f.change(cur.duration, prev.duration)), tile('users', f.num(cur.users), f.change(cur.users, prev.users), '%', 'users') ]; if (measures.key_events) { list.push(tile('key_events', f.num(cur.key_events), f.change(cur.key_events, prev.key_events), '%', 'key_events')); } if (measures.revenue) { list.push(tile('revenue', f.euro(cur.revenue), f.change(cur.revenue, prev.revenue))); } list.sort(function (a, b) { return (b.key === self.prim) - (a.key === self.prim); }); return list; }, chart: function (daily, m) { var n = daily.dates.length, step = n > 31 ? 14 : 7; return { labels: daily.dates.map(function (d, i) { return i % step === 0 ? f.dayWeek(d) : ''; }), titles: daily.dates.map(function (d, i) { return f.dayWeek(d) + ' vs ' + f.dayWeek(daily.prev_dates[i]); }), series: [ { label: t('ga4n.m.' + m) + ' · ' + t('ga4n.now'), color: BLUE, values: daily.cur[m], strong: true }, { label: t('ga4n.prev.' + this.compare), color: GREY, values: daily.prev[m], dash: true } ] }; }, rows: function (list) { if (!list) { return null; } return list.map(function (r) { return angular.extend({}, r, { dS: ch(r, 'sessions'), dE: ch(r, 'engaged'), dV: ch(r, 'views'), dK: ch(r, 'key_events'), dR: ch(r, 'revenue'), dRate: r.prev && !none(r.cur.eng_rate) && !none(r.prev.eng_rate) ? r.cur.eng_rate - r.prev.eng_rate : null, isNew: !r.prev && (r.cur.sessions || r.cur.views || 0) > 0 }); }); }, // ---- Wat veranderde: het verschil met de vorige periode, verdeeld over kanalen (of bronnen) en pagina's ---- changed: function (v, head) { if (!head) { return null; } var m = this.prim, self = this, fo = v.focus; var now = head.totals.cur[m] || 0, before = head.totals.prev[m] || 0; var out = { now: f.num(now), diff: now - before, diffText: (now >= before ? '+' : '−') + f.num(Math.abs(now - before)), pct: f.change(now, before), m: t('ga4n.m.' + m).toLowerCase(), groups: [], warns: [] }; var diffOf = function (r) { return (r.cur[m] || 0) - (r.prev ? r.prev[m] || 0 : 0); }; var group = function (key, list, act, label) { if (!list || !list.length) { return; } var rows = list.map(function (r) { return { label: label ? label(r) : r.key, val: r.key, act: act, diff: diffOf(r), isNew: r.isNew }; }) .filter(function (r) { return r.diff !== 0 && r.val !== '(not set)'; }); rows.sort(function (a, b) { return Math.abs(b.diff) - Math.abs(a.diff); }); rows = rows.slice(0, key === 'pages' ? 6 : 5); var max = rows.reduce(function (a, r) { return Math.max(a, Math.abs(r.diff)); }, 0) || 1; rows.forEach(function (r) { r.w = 50 * Math.abs(r.diff) / max; r.text = (r.diff > 0 ? '+' : '−') + f.num(Math.abs(r.diff)); }); if (rows.length) { out.groups.push({ key: key, title: t('ga4v.changed.' + key), rows: rows }); } }; if (fo === 'site') { group('channels', v.channels, 'channel'); } if (fo === 'channel') { group('sources', v.sources, 'source'); } if (fo === 'page' || fo === 'path') { group('channels', v.channels, 'channel'); group('sources', v.sources, 'source'); } if (fo !== 'page' && fo !== 'path') { group('pages', v.landing, 'page'); } // veel sessies en bijna niemand betrokken: lijkt op bots (v.channels || []).forEach(function (r) { if ((r.cur.sessions || 0) >= 100 && !none(r.cur.eng_rate) && r.cur.eng_rate < 10) { out.warns.push({ text: t('ga4n.note.low_engagement', { key: r.key, sessions: f.num(r.cur.sessions), rate: f.pct(r.cur.eng_rate) }), act: 'channel', val: r.key }); } }); out.warns = out.warns.slice(0, 2); return out; }, // ---- de kaarten met lijsten (Overzicht en Herkomst), per focus; elke regel is een filter ---- cards: function (v) { var self = this, fo = v.focus, tab = this.tab, m = this.prim, out = []; if (tab !== 'overview' && tab !== 'sources') { return out; } var lim = function (key, n) { return self.more[key] || n || 8; }; var num = function (r, key) { return { num: f.num(r.cur[key]), delta: key === 'engaged' ? r.dE : key === 'views' ? r.dV : r.dS, isNew: r.isNew }; }; var card = function (key, title, sub, all, mk, extra) { if (all === undefined) { return; } var n = lim(key, tab === 'sources' ? 15 : 8); // alleen wat nu bezoek heeft (een bron die alleen vroeger bezoek gaf, telt mee in Wat veranderde) var list = (all || []).filter(function (r) { return !r.cur || (r.cur.sessions || r.cur.views || 0) > 0; }); var rows = list.slice(0, n).map(mk); out.push(angular.extend({ key: key, title: title, sub: sub, loading: all === null, total: list.length, limit: n, rows: rows, rateCol: rows.some(function (r) { return r.rate !== undefined; }) }, extra || {})); }; var ga = function (act, label) { return function (r) { return angular.extend({ label: label ? label(r) : r.key, act: act, val: r.key, rate: r.cur.eng_rate, on: self.q[act] === r.key, bar: r.share !== undefined ? r.share : null, color: r.color, ai: act === 'source' && AI.test(r.key) }, num(r, m)); }; }; var withShare = function (list) { if (!list) { return list; } var tot = list.reduce(function (a, r) { return a + (r.cur[m] || 0); }, 0); return list.map(function (r) { return angular.extend({}, r, { share: tot ? 100 * (r.cur[m] || 0) / tot : 0 }); }); }; var refs = function (r) { return { label: r.ref.replace(/^https?:\/\/(www\.)?/, ''), href: r.ref, host: r.host, sub: '→ ' + r.page, subAct: 'path', subVal: r.page, num: f.num(r.cur[m]), delta: r.prev ? f.change(r.cur[m], r.prev[m]) : null, isNew: !r.prev, ai: AI.test(r.host) }; }; var mcol = t('ga4n.m.' + m); var channels = function () { card('channels', t('ga4v.card.channels'), t('ga4v.sub.channels'), v.channels, ga('channel'), { col: mcol, legend: true }); }; var sources = function () { card('sources', self.q.channel ? t('ga4v.card.sources_in', { c: self.q.channel }) : t('ga4v.card.sources'), t('ga4v.sub.sources'), withShare(v.sources), ga('source'), { col: mcol }); }; var referrers = function () { // leeg = geen kaart (bij een pagina die niemand van buiten linkt) if (v.referrers && !v.referrers.filter(function (r) { return r.cur.sessions || r.cur.views; }).length) { return; } card('referrers', t('ga4v.card.referrers'), self.q.source ? t('ga4v.sub.referrers_source', { s: self.q.source }) : t('ga4v.sub.referrers'), v.referrers, refs, { col: mcol }); }; var landing = function (title) { card('landing', title || t('ga4v.card.landing'), fo === 'path' ? t('ga4v.sub.landing_path') : t('ga4v.sub.landing'), withShare(v.landing), function (r) { return angular.extend({ label: r.key, act: 'page', val: r.key, rate: r.cur.eng_rate, bar: r.share }, num(r, m)); }, { col: mcol }); }; var next = function () { var own = bare(self.q.page); var list = v.next ? withShare(v.next.filter(function (r) { return bare(r.key) !== own; })) : v.next; card('next', t('ga4v.card.next'), t('ga4v.sub.next'), list, function (r) { return angular.extend({ label: r.key, act: 'path', val: r.key, bar: r.share }, num(r, 'views')); }, { col: t('ga4n.m.views') }); }; if (tab === 'overview') { if (fo === 'site') { channels(); landing(); sources(); referrers(); } if (fo === 'channel') { sources(); landing(); referrers(); } if (fo === 'source') { referrers(); landing(); } if (fo === 'page') { channels(); sources(); referrers(); next(); } if (fo === 'path') { channels(); landing(t('ga4v.card.landing_path')); sources(); referrers(); } } else { sources(); referrers(); } return out; }, // ---- Pagina's: segmenten, uitschieters, tabel met herkomst ---- pagesView: function (list, mix, avg) { var self = this, all = this.pageMode === 'all', m = all ? 'views' : this.prim; if (!list) { return { loading: true }; } var rows = this.rows(list); var tot = rows.reduce(function (a, r) { return a + (r.cur[m] || 0); }, 0); var seen = {}; rows.forEach(function (r) { var now = r.cur[m] || 0, before = r.prev ? r.prev[m] || 0 : 0, diff = now - before; var pct = r.prev && before ? 100 * diff / before : null; r.diff = diff; r.share = tot ? 100 * now / tot : 0; r.segs = { up: !!r.prev && diff >= 5 && pct !== null && pct >= 20, down: !!r.prev && diff <= -5 && pct !== null && pct <= -20, 'new': !r.prev && now >= 3, attention: !all && (r.cur.sessions || 0) >= 30 && !none(r.cur.eng_rate) && !none(avg) && r.cur.eng_rate < avg / 2, conv: (r.cur.key_events || 0) > 0 }; r.low = r.segs.attention; var mx = mix && mix[r.key]; if (mx) { r.mix = mx.slice(0, 5).map(function (x) { seen[x[0]] = (seen[x[0]] || 0) + x[1]; return { label: x[0], value: x[1], color: chColor(x[0]) }; }); r.mainCh = mx[0][0]; r.mainPct = Math.round(100 * mx[0][1] / mx.reduce(function (a, x) { return a + x[1]; }, 0)); } }); var counts = {}; SEGS.forEach(function (s) { counts[s] = s === 'all' ? rows.length : rows.filter(function (r) { return r.segs[s]; }).length; }); var shown = this.seg === 'all' ? rows : rows.filter(function (r) { return r.segs[self.seg]; }); if (this.seg === 'up') { shown.sort(function (a, b) { return b.diff - a.diff; }); } else if (this.seg === 'down') { shown.sort(function (a, b) { return a.diff - b.diff; }); } else { this.sortList(shown); } var top = function (fn, sort, n) { return rows.filter(function (r) { return r.key !== '(not set)' && fn(r); }).sort(sort).slice(0, n || 5); }; var hl = [ { key: 'up', rows: top(function (r) { return r.segs.up || (r.segs['new'] && r.diff >= 5); }, function (a, b) { return b.diff - a.diff; }) }, { key: 'down', rows: top(function (r) { return r.segs.down; }, function (a, b) { return a.diff - b.diff; }) } ]; if (!all) { hl.push({ key: 'attention', rows: top(function (r) { return r.segs.attention; }, function (a, b) { return b.cur.sessions - a.cur.sessions; }) }); } hl.forEach(function (x) { x.title = t('ga4v.hl.' + x.key); x.rows = x.rows.map(function (r) { return { key: r.key, text: x.key === 'attention' ? f.pct(r.cur.eng_rate, 0) + ' ' + t('ga4n.engaged_short') : (r.diff > 0 ? '+' : '−') + f.num(Math.abs(r.diff)) }; }); }); var n = this.more.pages || 50; return { m: m, counts: counts, total: shown.length, limit: n, rows: shown.slice(0, n), hl: hl, legend: Object.keys(seen).sort(function (a, b) { return seen[b] - seen[a]; }).map(function (k) { return { label: k, color: chColor(k) }; }) }; }, sortList: function (list) { var s = this.sort, get = function (r) { return s.key === 'key' ? r.key : (none(r.cur[s.key]) ? -1 : r.cur[s.key]); }; list.sort(function (a, b) { var x = get(a), y = get(b); var r = typeof x === 'string' ? x.localeCompare(y) : x - y; return s.desc ? -r : r; }); } }; $scope.$watch('c.sort', function (n, o) { if (n !== o) { c.refresh(); } }, true); // filters en weergave in de URL: zonder de route te herladen (reloadOnSearch false), opnieuw ophalen $scope.$on('$routeUpdate', function () { var seg = c.seg, mode = c.pageMode; c.readUrl(); c.tabs.forEach(function (x) { x.href = BASE + SLUG[x.key] + c.qs(); }); if (c.pageMode !== mode) { c.sort = { key: c.pageMode === 'all' ? 'views' : c.prim, desc: true }; } if (c.snapshot() !== c.loaded) { c.load(); } else if (c.seg !== seg) { c.more = {}; c.refresh(); } }); c.readUrl(); c.sort = { key: c.pageMode === 'all' && c.tab === 'pages' ? 'views' : c.prim, desc: true }; c.init(); }]); // Analytics v3 (proef, alleen Arjen): inzoomen. /analytics3 (Overzicht), /analytics3/herkomst, /paginas, /publiek, /conversies. // Een klik gaat een niveau dieper (Arjen 2026-11-02: "als je iets klikt zoom je in"): Herkomst kanaal › bron › verwijzende // pagina's of landingspagina's; Pagina's map › submap › pagina (het rapport van die pagina); Publiek land › stad, apparaat › // browser. Bovenaan het pad; tegels, grafiek en Opvallend gaan over het niveau waar je bent. Apparaat, land en periode zijn // filters die overal gelden. Ontwerp: canvas "Analytics ontwerp" (rij Inzoomen), docs/proef/analytics-v3.md. Server: API v1 // analytics.view (rest/include/analytics/), met het deel tree (mappen) en het filter folder. var ga4zState = { site: null, cache: {} }; app.controller('ga4zoom', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt) { // proef, alleen Arjen (docs/proef/README.md) if (!$rootScope.user || !$rootScope.user.owner) { $location.path('/ga4'); return; } var BASE = '/proef/analytics-v3'; var TABS = { '': 'overview', herkomst: 'sources', paginas: 'pages', publiek: 'audience', conversies: 'conversions' }; var SLUG = { overview: '', sources: '/herkomst', pages: '/paginas', audience: '/publiek', conversions: '/conversies' }; // het pad (per tab) en de filters (gelden overal en blijven staan als je van tab wisselt) var PATH = ['channel', 'source', 'folder', 'page', 'dim']; var FILTERS = ['device', 'country']; var KEEP = FILTERS.concat(['days', 'compare']); var SERVER = ['channel', 'source', 'folder', 'page', 'device', 'country']; // kanalen waar een bron naar de pagina's gaat die naar je linken; de rest gaat naar de landingspagina's var REF = ['Referral', 'Organic Social', 'AI Assistant', 'Email', 'Paid Social', 'Organic Video', 'Display', 'Affiliates']; // wat je ziet als je op een kanaal klikt var NEXT = { 'Organic Search': 'engine', 'Direct': 'pages', 'Referral': 'site', 'AI Assistant': 'ai', 'Organic Social': 'network', 'Paid Social': 'network' }; var DIMS = ['country', 'deviceCategory', 'browser', 'newVsReturning', 'language']; // Publiek: op welke regels je kunt inzoomen, en waarnaar var DEEPER = { country: { filter: 'country', dim: 'city' }, deviceCategory: { filter: 'device', dim: 'browser' } }; var PRIM_KEY = 'mt.ga4n.prim'; var BLUE = '#2563eb', GREY = '#9ca3af'; var LIMIT = 15; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (ga4zState.site !== h.siteId()) { ga4zState = { site: h.siteId(), cache: {} }; } var prim = 'sessions'; try { prim = window.localStorage.getItem(PRIM_KEY) === 'engaged' ? 'engaged' : 'sessions'; } catch (e) { prim = 'sessions'; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; var dur = function (s) { if (none(s)) { return '–'; } s = Math.round(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; var ch = function (r, m) { return r.prev ? f.change(r.cur[m], r.prev[m]) : null; }; // de mappen boven een pagina of map: /a/b/c → ['/a/', '/a/b/'] var folders = function (path) { var parts = String(path || '').split('/').filter(function (x) { return x; }), out = [], acc = '/'; var n = /\/$/.test(path) ? parts.length : parts.length - 1; for (var i = 0; i < n; i++) { acc += parts[i] + '/'; out.push(acc); } return out; }; var c = $scope.c = { f: f, dur: dur, tab: TABS[$routeParams.tab || ''] || 'overview', prim: prim, chartMetric: prim, tabs: [], q: {}, d: {}, v: {}, more: {}, devices: ['desktop', 'mobile', 'tablet'], dims: DIMS, dayOpts: [], compareOpts: [], init: function () { var self = this; this.tabs = ['overview', 'sources', 'pages', 'audience', 'conversions'].map(function (k) { return { key: k, label: '', href: BASE + SLUG[k] + self.qs() }; }); $translate.onReady(function () { self.tabs = self.tabs.map(function (x) { return angular.extend({}, x, { label: t('ga4z.tab.' + x.key) }); }); self.dayOpts = [7, 28, 90].map(function (n) { return { v: n, l: t('ga4n.days', { n: n }) }; }); self.compareOpts = ['prev', 'year'].map(function (k) { return { v: k, l: t('ga4n.prev.' + k) }; }); self.refresh(); }); this.load(); }, // ---- de URL ---- readUrl: function () { var s = $location.search(), q = {}; PATH.concat(FILTERS).forEach(function (k) { if (s[k]) { q[k] = String(s[k]); } }); if (q.dim && DIMS.concat(['city']).indexOf(q.dim) < 0) { delete q.dim; } this.q = q; this.days = [7, 28, 90].indexOf(+s.days) > -1 ? +s.days : 28; this.compare = s.compare === 'year' ? 'year' : 'prev'; }, // de filters en de periode voor een link naar een tab (het pad hoort bij de tab en gaat niet mee) qs: function (extra) { var s = angular.extend({}, $location.search(), extra || {}), parts = []; KEEP.concat(Object.keys(extra || {})).forEach(function (k, i, all) { if (all.indexOf(k) === i && s[k]) { parts.push(k + '=' + encodeURIComponent(s[k])); } }); return parts.length ? '?' + parts.join('&') : ''; }, set: function (k, v) { $location.search(k, v === null || v === undefined || v === '' ? null : v); }, // naar een tab, met een pad (de filters gaan mee) go: function (tab, path) { var s = $location.search(), n = {}; KEEP.forEach(function (k) { if (s[k]) { n[k] = s[k]; } }); angular.extend(n, path || {}); Object.keys(n).forEach(function (k) { if (n[k] === null || n[k] === undefined || n[k] === '') { delete n[k]; } }); $location.path(BASE + SLUG[tab]).search(n); }, // inzoomen binnen deze tab: het pad wordt dit; wat hier een filter is (kanaal en bron buiten Herkomst, een pagina buiten // Pagina's) blijft staan zoom: function (path) { var q = this.q, keep = {}; (this.tab !== 'sources' ? ['channel', 'source'] : []).concat(this.tab !== 'pages' ? ['page'] : []).forEach(function (k) { if (q[k]) { keep[k] = q[k]; } }); this.go(this.tab, angular.extend(keep, path)); }, act: function (a) { if (!a) { return; } if (a.tab === this.tab) { this.zoom(a.path); } else { this.go(a.tab, a.path); } }, setPrim: function (p) { if (p === this.prim) { return; } this.prim = p; this.chartMetric = p; try { window.localStorage.setItem(PRIM_KEY, p); } catch (e) { /* privévenster */ } this.load(); }, setChart: function (m) { this.chartMetric = m; this.refresh(); }, setDim: function (m) { this.go('audience', { dim: m === 'country' ? null : m }); }, removeFilter: function (k) { this.set(k, null); if (k === 'channel') { this.set('source', null); } }, withOrigin: function (p) { return this.tab === 'sources' ? angular.extend({ channel: this.q.channel, source: this.q.source }, p) : p; }, showMore: function (k) { this.more[k] = (this.more[k] || LIMIT) + 25; this.refresh(); }, // ---- waar je bent ---- level: function () { var q = this.q; if (this.tab === 'sources') { return q.source ? 2 : q.channel ? 1 : 0; } if (this.tab === 'pages') { return q.page ? 'page' : 'folder'; } return 0; }, noteBy: function () { if (this.tab === 'sources') { return this.q.source ? 'none' : this.q.channel ? 'source' : 'channel'; } return this.q.channel ? 'source' : 'channel'; }, isRef: function () { return REF.indexOf(this.q.channel) > -1 || (!this.q.channel && /referral|social/.test(this.q.source || '')); }, audienceDim: function () { var dim = this.q.dim || 'country', deep = DEEPER[dim]; return deep && this.q[deep.filter] ? deep.dim : dim; }, // de filters voor de server: het pad en de filters, behalve wat dit deel niet wil filters: function (skip) { var q = this.q, out = {}; SERVER.forEach(function (k) { if (q[k] && (skip || []).indexOf(k) < 0) { out[k] = q[k]; } }); return out; }, // welke delen deze tab op dit niveau ophaalt: [{slot, part, filters?, limit?}] slots: function () { var S = [], tab = this.tab, lv = this.level(), q = this.q; var add = function (slot, part, extra) { S.push(angular.extend({ slot: slot, part: part }, extra || {})); }; add('head', 'head'); // Opvallend over dit niveau: de kanalen, de bronnen in het kanaal, of (dieper) alleen de pagina's if (tab !== 'audience' && tab !== 'conversions') { add('notable', 'notable', { by: this.noteBy() }); } // het aandeel van waar je bent: de totalen zonder pad if ((tab === 'sources' && lv) || (tab === 'pages' && (q.folder || q.page))) { add('all', 'head', { filters: this.filters(['channel', 'source', 'folder', 'page']) }); } if (tab === 'overview') { add('channels', 'channels'); add('landing', 'landing'); } if (tab === 'sources') { if (lv === 0) { add('main', 'channels'); } if (lv === 1) { if (q.channel === 'Direct') { add('main', 'landing'); } else { add('main', 'sources'); add('side', 'landing'); } } if (lv === 2) { if (this.isRef()) { add('main', 'referrers'); add('side', 'landing'); } else { add('main', 'landing'); } if (q.channel) { add('siblings', 'sources', { filters: this.filters(['source']) }); } } } if (tab === 'pages') { if (lv === 'page') { add('main', 'channels'); add('next', 'pages'); } else { // op het hoogste niveau zonder mapfilter, zodat ook (not set) meetelt (net als in de tegels) add('main', 'tree'); } } if (tab === 'audience') { add('main', 'audience', { dim: this.audienceDim() }); } if (tab === 'conversions') { add('channels', 'channels'); add('landing', 'landing', { limit: 500 }); add('events', 'events'); } return S; }, body: function (s) { var b = { site_id: h.siteId(), part: s.part, days: this.days, compare: this.compare, prim: this.prim, filters: s.filters || this.filters() }; if (s.dim) { b.dim = s.dim; } if (s.by && s.by !== 'channel') { b.by = s.by; } if (s.limit) { b.limit = s.limit; } return b; }, fetch: function (body, put) { var self = this, key = JSON.stringify(body); if (ga4zState.cache[key]) { put(ga4zState.cache[key]); } $http.post('/rest/v1/analytics.view', body).then(function (res) { ga4zState.cache[key] = res.data.data; put(res.data.data); self.refresh(); }, function (res) { put({ state: 'error', message: res.status === 403 ? t('ga4n.no_access') : '' }); self.refresh(); }); }, snapshot: function () { return JSON.stringify([this.q, this.days, this.compare, this.tab]); }, load: function () { var self = this, gen = (this.gen = (this.gen || 0) + 1); this.readUrl(); this.loaded = this.snapshot(); this.d = {}; this.more = {}; this.has = {}; this.slots().forEach(function (s) { self.has[s.slot] = true; self.fetch(self.body(s), function (data) { if (gen === self.gen) { self.d[s.slot] = data; } }); }); this.refresh(); }, pageUrl: function (path) { return 'https://' + ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/\/$/, '') + path; }, // ---- wat de pagina toont ---- refresh: function () { var self = this, d = this.d, v = {}, q = this.q, lv = this.level(); // null = nog niet binnen; een deel met een fout geeft een lege lijst var part = function (slot) { var x = d[slot]; return x ? (x.state === 'ok' ? x[x.part] : null) : undefined; }; var head = part('head'); v.state = this.stateOf(); v.measures = (d.head && d.head.measures) || {}; v.period = (d.head || {}).period || null; if (v.period) { v.periodText = t('ga4n.period.' + v.period.compare, { from: f.day(v.period.cur.from), to: f.day(v.period.cur.to), pfrom: f.day(v.period.prev.from), pto: f.day(v.period.prev.to) }); } v.kpis = head ? this.kpis(head.totals, v.measures) : null; v.chart = head ? this.chart(head.daily, this.chartMetric) : null; v.crumbs = this.crumbs(); v.back = v.crumbs.length > 1 ? v.crumbs[v.crumbs.length - 2] : null; // het aandeel van waar je bent var all = part('all'); if (head && all && all.totals.cur.sessions) { v.share = { s: f.pct(100 * head.totals.cur.sessions / all.totals.cur.sessions, 1), e: all.totals.cur.engaged ? f.pct(100 * head.totals.cur.engaged / all.totals.cur.engaged, 1) : '–' }; } var notable = part('notable'); v.notable = notable ? notable.map(function (n) { return self.note(n); }) : (d.notable ? [] : null); v.filterList = FILTERS.filter(function (k) { return q[k] && !(self.tab === 'audience' && DEEPER[q.dim || 'country'] && DEEPER[q.dim || 'country'].filter === k); }) .map(function (k) { return { key: k, label: t('ga4n.filter.' + k), value: k === 'device' ? t('ga4n.device.' + q[k]) : q[k] }; }); if (this.tab !== 'pages' && q.page) { v.filterList.push({ key: 'page', label: t('ga4n.filter.page'), value: q.page }); } if (this.tab !== 'sources') { ['channel', 'source'].forEach(function (k) { if (q[k]) { v.filterList.push({ key: k, label: t('ga4n.filter.' + k), value: q[k] }); } }); } v.main = this.mainList(part('main'), lv); v.side = this.sideList(part('side')); v.siblings = this.siblings(part('siblings')); v.next = this.nextList(part('next')); if (this.tab === 'overview' || this.tab === 'conversions') { v.channels = this.list('channels', part('channels'), 'channel'); v.landing = this.list('landing', part('landing'), 'landing'); } if (this.tab === 'conversions') { var ev = part('events'); v.events = ev === undefined ? null : (ev || []); v.convLanding = v.landing && v.landing.rows ? v.landing.rows.slice().sort(function (a, b) { return (b.raw.cur.key_events || 0) - (a.raw.cur.key_events || 0); }).slice(0, 15) : null; } this.v = v; }, stateOf: function () { var all = Object.keys(this.d).map(function (k) { return this.d[k]; }, this); if (all.some(function (x) { return x && x.state === 'connect'; })) { return 'connect'; } return all.some(function (x) { return x && x.state === 'error'; }) ? 'error' : 'ok'; }, // het pad bovenaan: [{label, path}], de laatste is waar je bent crumbs: function () { var q = this.q, out = []; if (this.tab === 'sources') { out.push({ label: t('ga4z.crumb.channels'), path: {} }); if (q.channel) { out.push({ label: q.channel, path: { channel: q.channel } }); } if (q.source) { out.push({ label: q.source, path: { channel: q.channel, source: q.source } }); } } if (this.tab === 'pages') { out.push({ label: t('ga4z.crumb.pages'), path: {} }); folders(q.page || q.folder || '').forEach(function (x) { out.push({ label: x, path: { folder: x } }); }); if (q.page) { out.push({ label: q.page, path: { page: q.page } }); } } if (this.tab === 'audience') { var dim = q.dim || 'country', deep = DEEPER[dim]; out.push({ label: t('ga4z.crumb.audience.' + dim), path: { dim: q.dim } }); if (deep && q[deep.filter]) { var p = { dim: q.dim }; p[deep.filter] = q[deep.filter]; out.push({ label: deep.filter === 'device' ? t('ga4n.device.' + q.device) : q[deep.filter], path: p }); } } return out; }, // een regel: label, eronder wat je krijgt als je klikt, het hoofdgetal met verschil, aandeel, betrokkenheid row: function (r, label, act, sub, tot) { var m = this.prim; return { label: label, sub: sub || '', act: act, raw: r, num: f.num(r.cur[m]), delta: ch(r, m), isNew: !r.prev && (r.cur[m] || 0) > 0, other: f.num(r.cur[m === 'sessions' ? 'engaged' : 'sessions']), rate: r.cur.eng_rate, low: !none(r.cur.eng_rate) && r.cur.eng_rate < 10 && (r.cur.sessions || 0) >= 30, share: tot ? 100 * (r.cur[m] || 0) / tot : 0 }; }, // een lijst met hooguit LIMIT regels (of meer na "Meer") list: function (key, rows, kind) { if (rows === undefined) { return { loading: true, rows: [] }; } rows = (rows || []).filter(function (r) { return r.cur && (r.cur.sessions || r.cur.views || 0) > 0; }); var self = this, m = this.prim, tot = rows.reduce(function (a, r) { return a + (r.cur[m] || 0); }, 0); var n = this.more[key] || LIMIT; return { total: rows.length, limit: n, rows: rows.slice(0, n).map(function (r) { // een pagina (pagina-rapport of filter) gaat mee als filter if (kind === 'channel') { return self.row(r, r.key, { tab: 'sources', path: { channel: r.key, page: self.q.page } }, t('ga4z.next.' + (NEXT[r.key] || 'source')), tot); } if (kind === 'source') { return self.row(r, r.key, { tab: 'sources', path: { channel: self.q.channel, source: r.key, page: self.q.page } }, REF.indexOf(self.q.channel) > -1 ? t('ga4z.next.refpages') : t('ga4z.next.pages'), tot); } // vanuit Herkomst: het rapport van de pagina voor dat kanaal en die bron (als filter) if (kind === 'landing') { return self.row(r, r.key, { tab: 'pages', path: self.withOrigin({ page: r.key }) }, '', tot); } if (kind === 'tree') { return r.kind === 'folder' ? self.row(r, r.key, { tab: 'pages', path: { folder: r.key } }, t('ga4z.folder_pages', { n: f.num(r.pages) }), tot) : self.row(r, r.key, { tab: 'pages', path: { page: r.key } }, '', tot); } if (kind === 'audience') { var deep = DEEPER[self.q.dim || 'country'], deeper = deep && !self.q[deep.filter] && self.audienceDim() === (self.q.dim || 'country'); var label = self.audienceDim() === 'deviceCategory' ? t('ga4n.device.' + r.key) : r.key, p = { dim: self.q.dim }; if (deeper) { p[deep.filter] = r.key; } return self.row(r, label, deeper ? { tab: 'audience', path: p } : null, deeper ? t('ga4z.next.' + deep.dim) : '', tot); } return self.row(r, r.key, null, '', tot); }) }; }, mainList: function (rows, lv) { var q = this.q, tab = this.tab, out; if (tab === 'sources') { if (lv === 0) { out = this.list('main', rows, 'channel'); out.title = t('ga4z.list.channels'); out.sub = t('ga4z.list.channels_sub'); } if (lv === 1) { out = this.list('main', rows, q.channel === 'Direct' ? 'landing' : 'source'); out.title = q.channel === 'Direct' ? t('ga4z.list.landing') : t('ga4z.list.sources', { c: q.channel }); out.sub = q.channel === 'Direct' ? t('ga4z.list.landing_sub') : t('ga4z.list.sources_sub'); } if (lv === 2) { if (this.isRef()) { out = this.refList(rows); } else { out = this.list('main', rows, 'landing'); out.title = t('ga4z.list.landing'); out.sub = t('ga4z.list.landing_sub'); } } } if (tab === 'pages') { if (lv === 'page') { out = this.list('main', rows, 'channel'); out.title = t('ga4z.list.page_channels'); out.sub = t('ga4z.list.page_channels_sub'); } else { out = this.list('main', rows, 'tree'); out.title = q.folder ? t('ga4z.list.folder', { f: q.folder }) : t('ga4z.list.root'); out.sub = t('ga4z.list.tree_sub'); } } if (tab === 'audience') { out = this.list('main', rows && rows.rows, 'audience'); out.title = t('ga4z.list.audience.' + this.audienceDim()); out.sub = DEEPER[q.dim || 'country'] && this.audienceDim() === (q.dim || 'country') ? t('ga4z.list.audience_sub') : ''; } return out || null; }, // de pagina's van een site die naar je linken; klik = het rapport van de pagina waar ze binnenkwamen refList: function (rows) { var self = this, m = this.prim, out = { title: t('ga4z.list.refs', { s: this.q.source }), sub: t('ga4z.list.refs_sub') }; if (rows === undefined) { return angular.extend(out, { loading: true, rows: [] }); } // alleen pagina's van de site zelf: de verwijzer van een paginaweergave hoort niet altijd bij de bron van het bezoek var bare = function (x) { return String(x || '').toLowerCase().replace(/^(www|m|l|lm|mobile)\./, ''); }; var src = bare(String(this.q.source || '').split(' / ')[0]); rows = (rows || []).filter(function (r) { var hst = bare(r.host); return hst === src || hst.slice(-src.length - 1) === '.' + src || src.slice(-hst.length - 1) === '.' + hst; }); var tot = rows.reduce(function (a, r) { return a + (r.cur[m] || 0); }, 0), n = this.more.main || LIMIT; return angular.extend(out, { total: rows.length, limit: n, rows: rows.slice(0, n).map(function (r) { var x = self.row(r, r.ref.replace(/^https?:\/\/(www\.)?/, ''), { tab: 'pages', path: self.withOrigin({ page: r.page }) }, '→ ' + r.page, tot); x.href = r.ref; return x; }) }); }, sideList: function (rows) { if (!this.has.side) { return null; } var out = this.list('side', rows, 'landing'); out.title = t('ga4z.side.landing'); return out; }, siblings: function (rows) { var q = this.q; if (!rows) { return null; } var self = this, list = rows.filter(function (r) { return r.key !== q.source && (r.cur.sessions || 0) > 0; }).slice(0, 6); return list.length ? list.map(function (r) { return { label: r.key, num: f.num(r.cur[self.prim]), path: { channel: q.channel, source: r.key, page: q.page } }; }) : null; }, // pagina-rapport: wat ze daarna bekeken (zonder de pagina zelf) nextList: function (rows) { if (this.tab !== 'pages' || !this.q.page) { return null; } var own = this.q.page.replace(/\/$/, '') || '/'; if (rows === undefined) { return { loading: true, rows: [] }; } var list = (rows || []).filter(function (r) { return (r.key.replace(/\/$/, '') || '/') !== own && (r.cur.views || 0) > 0; }).slice(0, 10); return { rows: list.map(function (r) { return { label: r.key, num: f.num(r.cur.views), act: { tab: 'pages', path: { page: r.key } } }; }) }; }, kpis: function (tot, measures) { var cur = tot.cur, prev = tot.prev, self = this; var tile = function (key, value, delta, unit, pick) { return { key: key, label: t('ga4n.m.' + key), value: value, delta: delta, unit: unit || '%', pick: pick }; }; var list = [ tile('sessions', f.num(cur.sessions), f.change(cur.sessions, prev.sessions), '%', 'sessions'), tile('engaged', f.num(cur.engaged), f.change(cur.engaged, prev.engaged), '%', 'engaged'), tile('eng_rate', f.pct(cur.eng_rate), none(cur.eng_rate) || none(prev.eng_rate) ? null : cur.eng_rate - prev.eng_rate, 'pt'), tile('duration', dur(cur.duration), none(prev.duration) || !prev.duration ? null : f.change(cur.duration, prev.duration)), tile('users', f.num(cur.users), f.change(cur.users, prev.users), '%', 'users') ]; if (measures.key_events) { list.push(tile('key_events', f.num(cur.key_events), f.change(cur.key_events, prev.key_events), '%', 'key_events')); } if (measures.revenue) { list.push(tile('revenue', f.euro(cur.revenue), f.change(cur.revenue, prev.revenue))); } list.sort(function (a, b) { return (b.key === self.prim) - (a.key === self.prim); }); return list; }, chart: function (daily, m) { var n = daily.dates.length, step = n > 31 ? 14 : 7; return { labels: daily.dates.map(function (d, i) { return i % step === 0 ? f.dayWeek(d) : ''; }), titles: daily.dates.map(function (d, i) { return f.dayWeek(d) + ' vs ' + f.dayWeek(daily.prev_dates[i]); }), series: [ { label: t('ga4n.m.' + m) + ' · ' + t('ga4n.now'), color: BLUE, values: daily.cur[m], strong: true }, { label: t('ga4n.prev.' + this.compare), color: GREY, values: daily.prev[m], dash: true } ] }; }, // Opvallend: een kanaal of pagina aanklikken zoomt daarop in note: function (n) { var p = { key: n.key, now: f.num(n.now), before: f.num(n.before), pct: f.num(Math.abs(n.pct || 0)), m: t('ga4n.m.' + this.prim).toLowerCase(), sessions: f.num(n.sessions), rate: f.pct(n.rate), diff: f.num(Math.abs(n.diff || 0), 1) }; var tone = { channel_up: 'up', channel_new: 'up', page_up: 'up', page_new: 'up', engagement_up: 'up', channel_down: 'down', page_down: 'down', engagement_down: 'down', low_engagement: 'warn' }[n.kind]; var act = null; if (n.kind.indexOf('channel') === 0 || n.kind === 'low_engagement') { act = { tab: 'sources', path: this.noteBy() === 'source' ? { channel: this.q.channel, source: n.key, page: this.q.page } : { channel: n.key, page: this.q.page } }; } if (n.kind.indexOf('page') === 0) { act = { tab: 'pages', path: this.withOrigin({ page: n.key }) }; } return { tone: tone, text: t('ga4n.note.' + n.kind, p), act: act }; } }; // het pad en de filters staan in de URL: zonder de route te herladen opnieuw ophalen $scope.$on('$routeUpdate', function () { c.readUrl(); c.tabs.forEach(function (x) { x.href = BASE + SLUG[x.key] + c.qs(); }); if (c.snapshot() !== c.loaded) { c.load(); } }); c.readUrl(); c.init(); }]); // Analytics v4 (proef, alleen Arjen): v3 (inzoomen), maar de data en het niveau bepalen wat er staat (Arjen 2026-11-02: // "het kan 2 of 3 vragen zijn, maar gebaseerd op de data en waar we zijn"). Op elk niveau dezelfde vijf vragen: hoeveel, // verandert het, is het goed (betrokkenheid tegen de site), wat zit erin (de lijst waarop je inzoomt) en waarmee hangt // het samen (de andere as: bron → pagina's, pagina → herkomst). Een blok verschijnt alleen als de data het draagt; elk // blok heeft een geel kader met waarom (de getallen en de drempel), en wat is weggelaten staat er ook, met de reden. // De kaders zijn voor Arjen om de regels te beoordelen; ze zijn Nederlands en niet vertaald. Ontwerp en regels: // docs/proef/analytics-v4.md. Server: API v1 analytics.view (rest/include/analytics/), zoals v3. var ga4wState = { site: null, cache: {} }; app.controller('ga4why', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$translate', 'h', 'mtFmt', function ($scope, $rootScope, $location, $routeParams, $http, $translate, h, mtFmt) { // proef, alleen Arjen (docs/proef/README.md) if (!$rootScope.user || !$rootScope.user.owner) { $location.path('/ga4'); return; } var BASE = '/proef/analytics-v4'; var TABS = { '': 'overview', herkomst: 'sources', paginas: 'pages', publiek: 'audience', conversies: 'conversions' }; var SLUG = { overview: '', sources: '/herkomst', pages: '/paginas', audience: '/publiek', conversions: '/conversies' }; var PATH = ['channel', 'source', 'folder', 'page', 'dim']; var FILTERS = ['device', 'country']; var KEEP = FILTERS.concat(['days', 'compare']); var SERVER = ['channel', 'source', 'folder', 'page', 'device', 'country']; // kanalen waar een bron naar de pagina's gaat die naar je linken; de rest gaat naar de landingspagina's var REF = ['Referral', 'Organic Social', 'AI Assistant', 'Email', 'Paid Social', 'Organic Video', 'Display', 'Affiliates']; var NEXT = { 'Organic Search': 'engine', 'Direct': 'pages', 'Referral': 'site', 'AI Assistant': 'ai', 'Organic Social': 'network', 'Paid Social': 'network' }; var DIMS = ['country', 'deviceCategory', 'browser', 'newVsReturning', 'language']; var DEEPER = { country: { filter: 'country', dim: 'city' }, deviceCategory: { filter: 'device', dim: 'browser' } }; var PRIM_KEY = 'mt.ga4n.prim'; var BLUE = '#2563eb', GREY = '#9ca3af'; var LIMIT = 15; // de drempels: wanneer een blok of een markering een plek verdient var MIN = { small: 30, // minder sessies: alleen de regels (geen tegels, grafiek of vergelijking met de site) chartTotal: 150, // een grafiek pas vanaf 150 sessies in de periode ... chartDay: 5, // ... en gemiddeld 5 per dag; anders is de lijn ruis delta: 10, // een percentage verschil pas vanaf 10 in de vorige periode, anders "was N" mark: 30, // markeringen (groeit, daalt, stuitert, blijft) pas vanaf 30 sessies grow: 50, // groeit: +50% of meer drop: 30, // daalt: -30% of meer quality: 10, // betrokkenheid wijkt af van de site: 10 procentpunt (in de zin bovenaan) qualityRow: 15, // en per regel: 15 procentpunt dominant: 90, // één regel met 90% of meer: dat niveau overslaan deeper: 100, // Publiek: steden of browsers pas vanaf 100 sessies share: 1, // het aandeel van de site pas vanaf 1% connRows: 2 // de lijst ernaast pas vanaf 2 regels }; var t = function (k, v) { return $translate.instant(k, v); }; var f = mtFmt; if (ga4wState.site !== h.siteId()) { ga4wState = { site: h.siteId(), cache: {} }; } var prim = 'sessions'; try { prim = window.localStorage.getItem(PRIM_KEY) === 'engaged' ? 'engaged' : 'sessions'; } catch (e) { prim = 'sessions'; } var none = function (v) { return v === null || v === undefined || isNaN(v); }; var n = function (v) { return f.num(v || 0); }; var pct = function (v) { return none(v) ? '–' : f.pct(v, 0); }; var rateOf = function (c) { return c && c.sessions ? 100 * (c.engaged || 0) / c.sessions : null; }; var folders = function (path) { var parts = String(path || '').split('/').filter(function (x) { return x; }), out = [], acc = '/'; var k = /\/$/.test(path) ? parts.length : parts.length - 1; for (var i = 0; i < k; i++) { acc += parts[i] + '/'; out.push(acc); } return out; }; var c = $scope.c = { f: f, tab: TABS[$routeParams.tab || ''] || 'overview', prim: prim, chartMetric: prim, tabs: [], q: {}, d: {}, v: {}, more: {}, has: {}, devices: ['desktop', 'mobile', 'tablet'], dims: DIMS, dayOpts: [], compareOpts: [], init: function () { var self = this; this.tabs = ['overview', 'sources', 'pages', 'audience', 'conversions'].map(function (k) { return { key: k, label: '', href: BASE + SLUG[k] + self.qs() }; }); $translate.onReady(function () { self.tabs = self.tabs.map(function (x) { return angular.extend({}, x, { label: t('ga4z.tab.' + x.key) }); }); self.dayOpts = [7, 28, 90].map(function (k) { return { v: k, l: t('ga4n.days', { n: k }) }; }); self.compareOpts = ['prev', 'year'].map(function (k) { return { v: k, l: t('ga4n.prev.' + k) }; }); self.refresh(); }); this.load(); }, // ---- de URL (zoals v3) ---- readUrl: function () { var s = $location.search(), q = {}; PATH.concat(FILTERS).forEach(function (k) { if (s[k]) { q[k] = String(s[k]); } }); if (q.dim && DIMS.concat(['city']).indexOf(q.dim) < 0) { delete q.dim; } this.q = q; this.days = [7, 28, 90].indexOf(+s.days) > -1 ? +s.days : 28; this.compare = s.compare === 'year' ? 'year' : 'prev'; }, qs: function () { var s = $location.search(), parts = []; KEEP.forEach(function (k) { if (s[k]) { parts.push(k + '=' + encodeURIComponent(s[k])); } }); return parts.length ? '?' + parts.join('&') : ''; }, set: function (k, v) { $location.search(k, v === null || v === undefined || v === '' ? null : v); }, go: function (tab, path) { var s = $location.search(), x = {}; KEEP.forEach(function (k) { if (s[k]) { x[k] = s[k]; } }); angular.extend(x, path || {}); Object.keys(x).forEach(function (k) { if (x[k] === null || x[k] === undefined || x[k] === '') { delete x[k]; } }); $location.path(BASE + SLUG[tab]).search(x); }, zoom: function (path) { var q = this.q, keep = {}; (this.tab !== 'sources' ? ['channel', 'source'] : []).concat(this.tab !== 'pages' ? ['page'] : []).forEach(function (k) { if (q[k]) { keep[k] = q[k]; } }); this.go(this.tab, angular.extend(keep, path)); }, act: function (a) { if (!a) { return; } if (a.tab === this.tab) { this.zoom(a.path); } else { this.go(a.tab, a.path); } }, setPrim: function (p) { if (p === this.prim) { return; } this.prim = p; this.chartMetric = p; try { window.localStorage.setItem(PRIM_KEY, p); } catch (e) { /* privévenster */ } this.load(); }, setChart: function (m) { this.chartMetric = m; this.refresh(); }, setDim: function (m) { this.go('audience', { dim: m === 'country' ? null : m }); }, removeFilter: function (k) { this.set(k, null); if (k === 'channel') { this.set('source', null); } }, withOrigin: function (p) { return this.tab === 'sources' ? angular.extend({ channel: this.q.channel, source: this.q.source }, p) : p; }, showMore: function (k) { this.more[k] = (this.more[k] || LIMIT) + 25; this.refresh(); }, // ---- waar je bent ---- // 0 = bovenaan de tab; daar staan tegels in plaats van de zin level: function () { var q = this.q; if (this.tab === 'sources') { return q.source ? 2 : q.channel ? 1 : 0; } if (this.tab === 'pages') { return q.page ? 'page' : q.folder ? 'folder' : 0; } if (this.tab === 'audience') { var deep = DEEPER[q.dim || 'country']; return deep && q[deep.filter] ? 1 : 0; } return 0; }, isRef: function () { return REF.indexOf(this.q.channel) > -1 || (!this.q.channel && /referral|social/.test(this.q.source || '')); }, audienceDim: function () { var dim = this.q.dim || 'country', deep = DEEPER[dim]; return deep && this.q[deep.filter] ? deep.dim : dim; }, // wat op deze tab het pad is (de rest zijn filters): de site zonder het pad is de maat voor "is het goed" pathKeys: function () { if (this.tab === 'sources') { return ['channel', 'source']; } if (this.tab === 'pages') { return ['folder', 'page', 'channel', 'source']; } if (this.tab === 'audience') { var deep = DEEPER[this.q.dim || 'country']; return deep ? [deep.filter] : []; } return []; }, filters: function (skip) { var q = this.q, out = {}; SERVER.forEach(function (k) { if (q[k] && (skip || []).indexOf(k) < 0) { out[k] = q[k]; } }); return out; }, slots: function () { var S = [], tab = this.tab, lv = this.level(), q = this.q; var add = function (slot, part, extra) { S.push(angular.extend({ slot: slot, part: part }, extra || {})); }; add('head', 'head'); if (lv !== 0) { add('all', 'head', { filters: this.filters(this.pathKeys()) }); } if (tab === 'overview') { add('main', 'channels'); add('conn', 'landing'); } if (tab === 'sources') { if (lv === 0) { add('main', 'channels'); } if (lv === 1) { if (q.channel === 'Direct') { add('main', 'landing'); } else { add('main', 'sources'); add('conn', 'landing'); } } if (lv === 2) { if (this.isRef()) { add('main', 'referrers'); add('conn', 'landing'); } else { add('main', 'landing'); } } } if (tab === 'pages') { if (lv === 'page') { // met een bron in het pad is de herkomst al bekend; met alleen een kanaal: de bronnen daarin if (!q.source) { add('main', q.channel ? 'sources' : 'channels'); } add('next', 'pages'); } else { add('main', 'tree'); if (lv === 'folder') { add('conn', 'channels'); } } } if (tab === 'audience') { add('main', 'audience', { dim: this.audienceDim() }); if (lv === 1) { add('conn', 'landing'); } } if (tab === 'conversions') { add('main', 'channels'); add('conn', 'landing', { limit: 500 }); add('events', 'events'); } return S; }, body: function (s) { var b = { site_id: h.siteId(), part: s.part, days: this.days, compare: this.compare, prim: this.prim, filters: s.filters || this.filters() }; if (s.dim) { b.dim = s.dim; } if (s.limit) { b.limit = s.limit; } return b; }, fetch: function (body, put) { var self = this, key = JSON.stringify(body); if (ga4wState.cache[key]) { put(ga4wState.cache[key]); } $http.post('/rest/v1/analytics.view', body).then(function (res) { ga4wState.cache[key] = res.data.data; put(res.data.data); self.refresh(); }, function (res) { put({ state: 'error', message: res.status === 403 ? t('ga4n.no_access') : '' }); self.refresh(); }); }, snapshot: function () { return JSON.stringify([this.q, this.days, this.compare, this.tab]); }, load: function () { var self = this, gen = (this.gen = (this.gen || 0) + 1); this.readUrl(); this.loaded = this.snapshot(); this.d = {}; this.more = {}; this.has = {}; this.slots().forEach(function (s) { self.has[s.slot] = true; self.fetch(self.body(s), function (data) { if (gen === self.gen) { self.d[s.slot] = data; } }); }); this.refresh(); }, pageUrl: function (path) { return 'https://' + ($rootScope.user.site.domain || '').replace(/^https?:\/\//, '').replace(/\/$/, '') + path; }, // ---- wat de pagina toont ---- refresh: function () { var d = this.d, v = { skipped: [] }, q = this.q, lv = this.level(); this.promote = false; var part = function (slot) { var x = d[slot]; return x ? (x.state === 'ok' ? x[x.part] : null) : undefined; }; var head = part('head'), all = lv === 0 ? head : part('all'); v.state = this.stateOf(); v.measures = (d.head && d.head.measures) || {}; v.period = (d.head || {}).period || null; if (v.period) { v.periodText = t('ga4n.period.' + v.period.compare, { from: f.day(v.period.cur.from), to: f.day(v.period.cur.to), pfrom: f.day(v.period.prev.from), pto: f.day(v.period.prev.to) }); } v.crumbs = this.crumbs(); v.back = v.crumbs.length > 1 ? v.crumbs[v.crumbs.length - 2] : null; v.filterList = FILTERS.filter(function (k) { return q[k] && !(c.tab === 'audience' && DEEPER[q.dim || 'country'] && DEEPER[q.dim || 'country'].filter === k); }) .map(function (k) { return { key: k, label: t('ga4n.filter.' + k), value: k === 'device' ? t('ga4n.device.' + q[k]) : q[k] }; }); if (this.tab !== 'pages' && q.page) { v.filterList.push({ key: 'page', label: t('ga4n.filter.page'), value: q.page }); } if (this.tab !== 'sources') { ['channel', 'source'].forEach(function (k) { if (q[k]) { v.filterList.push({ key: k, label: t('ga4n.filter.' + k), value: q[k] }); } }); } // de maat voor "is het goed": de betrokkenheid van de site (zonder het pad, met de filters) this.avg = all ? rateOf(all.totals.cur) : null; // per regel vergelijken met het niveau zelf, niet met de site: op brabbels is de site 13% betrokken door veel // kort bezoek, dan zou bijna elke regel in Organic Search "blijft" zijn this.rowAvg = head ? rateOf(head.totals.cur) : null; if (head) { var cur = head.totals.cur, size = cur.sessions || 0; this.size = size; v.small = size < MIN.small; this.top(v, head, all, lv); this.chartBlock(v, head); } this.mainBlock(v, part('main'), lv); this.connBlock(v, part('conn'), lv); this.nextBlock(v, part('next')); if (this.tab === 'conversions') { this.convBlocks(v, part('events')); } v.skipped.push({ name: 'Opvallend', why: 'Geen eigen blok meer: wat opvalt (nieuw, groeit, daalt, stuitert, blijft) staat als markering bij de regel in de lijst. Drempels: zie het kader bij de lijst.' }); if (lv !== 0) { v.skipped.push({ name: 'Andere bronnen / zijwaarts', why: 'Weg: ← Terug en het pad doen hetzelfde.' }); } this.v = v; }, stateOf: function () { var list = Object.keys(this.d).map(function (k) { return this.d[k]; }, this); if (list.some(function (x) { return x && x.state === 'connect'; })) { return 'connect'; } return list.some(function (x) { return x && x.state === 'error'; }) ? 'error' : 'ok'; }, // vraag 1-3 bovenaan: tegels bovenaan een tab, daaronder één zin top: function (v, head, all, lv) { var cur = head.totals.cur, prev = head.totals.prev, m = this.prim, size = cur.sessions || 0; var label = t('ga4n.m.' + m).toLowerCase(); if (lv === 0 && size >= MIN.small) { var tile = function (key, value, delta, unit, pick) { return { key: key, label: t('ga4n.m.' + key), value: value, delta: delta, unit: unit || '%', pick: pick }; }; var list = [ tile('sessions', n(cur.sessions), f.change(cur.sessions, prev.sessions), '%', 'sessions'), tile('engaged', n(cur.engaged), f.change(cur.engaged, prev.engaged), '%', 'engaged'), tile('eng_rate', f.pct(cur.eng_rate), none(cur.eng_rate) || none(prev.eng_rate) ? null : cur.eng_rate - prev.eng_rate, 'pt') ]; var why = ['Bovenaan de tab: ' + n(size) + ' sessies, genoeg om te vergelijken (drempel ' + MIN.small + ').', 'Sessies, betrokken sessies en betrokkenheid beantwoorden "hoeveel" en "is het goed". Gebruikers en sessieduur zijn weggelaten: gebruikers loopt bijna gelijk met sessies, de duur zit al in betrokkenheid.']; if (v.measures.revenue) { list.push(tile('revenue', f.euro(cur.revenue), f.change(cur.revenue, prev.revenue))); why.push('Opbrengst erbij: de site meet opbrengst.'); } else if (v.measures.key_events) { list.push(tile('key_events', n(cur.key_events), f.change(cur.key_events, prev.key_events), '%', 'key_events')); why.push('Key events erbij: de site meet key events (geen opbrengst).'); } else { why.push('Geen tegel voor conversies: de site meet geen key events of opbrengst.'); } list.sort(function (a, b) { return (b.key === m) - (a.key === m); }); v.tiles = { list: list, why: why }; return; } // dieper (of klein): één zin met hoeveel, het verschil en de betrokkenheid tegen de site var parts = [], why = []; var now = cur[m] || 0, before = prev[m] || 0; parts.push(t('ga4w.sum.size', { n: n(now), m: label })); if (lv !== 0 && all && all.totals.cur[m]) { var share = 100 * now / all.totals.cur[m]; if (share >= MIN.share) { parts.push(t('ga4w.sum.share', { s: f.pct(share, share < 10 ? 1 : 0) })); why.push('Aandeel genoemd: ' + f.pct(share, 1) + ' van de site (drempel ' + MIN.share + '%).'); } else { why.push('Aandeel niet genoemd: ' + f.pct(share, 2) + ' van de site, onder de ' + MIN.share + '%; dat zegt niets.'); } } if (!now && !before) { v.summary = { text: t('ga4z.empty'), why: ['Geen bezoek in deze periode en de vorige: niets om te tonen.'] }; return; } if (!before) { parts.push(t('ga4w.sum.new')); why.push('Vorige periode niets: geen percentage.'); } else if (before < MIN.delta) { parts.push(t('ga4w.sum.was', { p: n(before) })); why.push('Vorige periode ' + before + ', onder de ' + MIN.delta + ': een percentage zou overdrijven, dus alleen het getal.'); } else { var ch = f.change(now, before); parts.push(t('ga4w.sum.change', { p: n(before), d: (ch >= 0 ? '+' : '') + f.num(ch, 0) + '%' })); why.push('Verschil in procenten: vorige periode ' + n(before) + ' (drempel ' + MIN.delta + ').'); } if (size >= MIN.small && !none(cur.eng_rate)) { var r = rateOf(cur), line = t('ga4w.sum.rate', { r: pct(r) }); if (lv !== 0 && !none(this.avg)) { var diff = r - this.avg; if (Math.abs(diff) >= MIN.quality) { line += ' (' + t(diff > 0 ? 'ga4w.sum.better' : 'ga4w.sum.worse', { a: pct(this.avg) }) + ')'; why.push('Betrokkenheid ' + pct(r) + ' tegen ' + pct(this.avg) + ' voor de site: ' + f.num(Math.abs(diff), 0) + ' punt verschil, genoemd vanaf ' + MIN.quality + '.'); } else { why.push('Betrokkenheid ' + pct(r) + ', site ' + pct(this.avg) + ': minder dan ' + MIN.quality + ' punt verschil, dus geen oordeel.'); } } parts.push(line); } else if (size < MIN.small) { why.push(n(size) + ' sessies, onder de ' + MIN.small + ': geen betrokkenheid en geen oordeel, bij zo weinig zegt een percentage niets.'); } v.summary = { text: parts.join(' · '), why: why }; if (lv === 0) { why.unshift('Bovenaan de tab, maar klein: een zin in plaats van tegels.'); } else { v.skipped.push({ name: 'Tegels', why: 'Alleen bovenaan een tab. Hier zegt de zin hetzelfde in één regel.' }); } }, chartBlock: function (v, head) { var daily = head.daily, days = daily.dates.length || 1, size = head.totals.cur.sessions || 0, perDay = size / days; if (size < MIN.chartTotal || perDay < MIN.chartDay) { v.skipped.push({ name: 'Grafiek', why: n(size) + ' sessies in ' + days + ' dagen (' + f.num(perDay, 1) + ' per dag). Een lijn pas vanaf ' + MIN.chartTotal + ' sessies en ' + MIN.chartDay + ' per dag; daaronder is het ruis.' }); return; } var m = this.chartMetric, step = days > 31 ? 14 : 7; v.chart = { data: { labels: daily.dates.map(function (x, i) { return i % step === 0 ? f.dayWeek(x) : ''; }), titles: daily.dates.map(function (x, i) { return f.dayWeek(x) + ' vs ' + f.dayWeek(daily.prev_dates[i]); }), series: [ { label: t('ga4n.m.' + m) + ' · ' + t('ga4n.now'), color: BLUE, values: daily.cur[m], strong: true }, { label: t('ga4n.prev.' + this.compare), color: GREY, values: daily.prev[m], dash: true } ] }, why: [n(size) + ' sessies, ' + f.num(perDay, 1) + ' per dag: genoeg voor een lijn (drempels ' + MIN.chartTotal + ' en ' + MIN.chartDay + ' per dag).', 'Beantwoordt "verandert het": deze periode tegen de vorige, per dag.'] }; }, // het pad bovenaan crumbs: function () { var q = this.q, out = []; if (this.tab === 'sources') { out.push({ label: t('ga4z.crumb.channels'), path: {} }); if (q.channel) { out.push({ label: q.channel, path: { channel: q.channel } }); } if (q.source) { out.push({ label: q.source, path: { channel: q.channel, source: q.source } }); } } if (this.tab === 'pages') { out.push({ label: t('ga4z.crumb.pages'), path: {} }); folders(q.page || q.folder || '').forEach(function (x) { out.push({ label: x, path: { folder: x } }); }); if (q.page) { out.push({ label: q.page, path: { page: q.page } }); } } if (this.tab === 'audience') { var dim = q.dim || 'country', deep = DEEPER[dim]; out.push({ label: t('ga4z.crumb.audience.' + dim), path: { dim: q.dim } }); if (deep && q[deep.filter]) { var p = { dim: q.dim }; p[deep.filter] = q[deep.filter]; out.push({ label: deep.filter === 'device' ? t('ga4n.device.' + q.device) : q[deep.filter], path: p }); } } return out; }, // een regel: hoofdgetal, verschil (of "was N" / nieuw), betrokkenheid en hooguit twee markeringen row: function (r, label, act, sub, tot, avg) { var m = this.prim, cur = r.cur || {}, prev = r.prev || {}, now = cur[m] || 0, before = prev[m] || 0; var s = cur.sessions || 0, ps = prev.sessions || 0, rate = rateOf(cur), ch = before ? f.change(now, before) : null, marks = []; var change = !before ? (now ? { kind: 'new' } : null) : before < MIN.delta ? { kind: 'was', text: t('ga4w.was', { n: n(before) }) } : { kind: 'pct', value: ch }; if (!ps && s >= MIN.mark) { marks.push({ k: 'new', tone: 'blue' }); } if (ps >= MIN.delta && s >= MIN.mark && ch !== null && ch >= MIN.grow) { marks.push({ k: 'grow', tone: 'green' }); } if (ps >= MIN.mark && ch !== null && ch <= -MIN.drop) { marks.push({ k: 'drop', tone: 'red' }); } if (s >= MIN.mark && !none(avg) && !none(rate)) { if (rate <= avg - MIN.qualityRow) { marks.push({ k: 'bounce', tone: 'amber' }); } if (rate >= avg + MIN.qualityRow) { marks.push({ k: 'stay', tone: 'green' }); } } return { label: label, sub: sub || '', act: act, raw: r, num: n(now), change: change, rate: rate, marks: marks.slice(0, 2).map(function (x) { return { tone: x.tone, text: t('ga4w.mark.' + x.k) }; }), share: tot ? 100 * now / tot : 0 }; }, // de lijst: regels met iets nu, de verdwenen regels apart, het aantal markeringen voor het kader list: function (key, rows, mk) { if (rows === undefined) { return { loading: true, rows: [], gone: [], why: [] }; } rows = rows || []; var m = this.prim, self = this; var live = rows.filter(function (r) { return r.cur && (r.cur.sessions || r.cur.views || 0) > 0; }); var gone = rows.filter(function (r) { return r.cur && !(r.cur.sessions || 0) && r.prev && (r.prev.sessions || 0) >= MIN.delta; }) .slice(0, 8).map(function (r) { return { label: r.key, was: n(r.prev[m] || r.prev.sessions) }; }); var tot = live.reduce(function (a, r) { return a + (r.cur[m] || 0); }, 0), lim = this.more[key] || LIMIT; var out = { total: live.length, limit: lim, gone: gone, tot: tot }; out.rows = live.slice(0, lim).map(function (r) { return mk(r, tot); }); out.why = []; return out; }, // het kader bij een lijst: waarom deze lijst hier, en wat de markeringen betekenen listWhy: function (L, reason) { if (L.loading) { return L; } var marked = L.rows.filter(function (r) { return r.marks.length; }).length; L.why = [reason, L.total + ' regels met bezoek' + (L.total > L.limit ? ', de eerste ' + L.limit + ' getoond' : '') + '.', 'Verschil in % pas vanaf ' + MIN.delta + ' in de vorige periode, anders "was N". Markeringen vanaf ' + MIN.mark + ' sessies: groeit (+' + MIN.grow + '%), daalt (-' + MIN.drop + '%), nieuw, stuitert / blijft (betrokkenheid ' + MIN.qualityRow + ' punt onder / boven ' + (none(this.rowAvg) ? 'dit niveau' : pct(this.rowAvg) + ', dit niveau zelf; niet de site, want die ligt door kort bezoek vaak laag') + '). Nu ' + marked + ' gemarkeerd.']; if (L.gone.length) { L.why.push(L.gone.length + ' verdwenen (vorige periode ' + MIN.delta + ' of meer, nu niets): als regel onder de lijst.'); } return L; }, mainBlock: function (v, rows, lv) { var self = this, q = this.q, tab = this.tab, avg = this.rowAvg, L = null; var chRow = function (r, tot) { return self.row(r, r.key, { tab: 'sources', path: { channel: r.key, page: q.page } }, t('ga4z.next.' + (NEXT[r.key] || 'source')), tot, avg); }; var landRow = function (r, tot) { return self.row(r, r.key, { tab: 'pages', path: self.withOrigin({ page: r.key }) }, '', tot, avg); }; if (tab === 'conversions' && rows) { // Conversies: de kanalen op key events, met de conversie per sessie eronder rows = rows.slice().sort(function (a, b) { return (b.cur.key_events || 0) - (a.cur.key_events || 0); }); chRow = function (r, tot) { var k = r.cur.key_events || 0, s = r.cur.sessions || 0; return self.row(r, r.key, { tab: 'sources', path: { channel: r.key } }, t('ga4w.conv_sub', { k: n(k), r: s ? f.pct(100 * k / s, 1) : '–' }), tot, avg); }; } if (tab === 'overview' || tab === 'conversions' || (tab === 'sources' && lv === 0)) { L = this.listWhy(angular.extend(this.list('main', rows, chRow), { title: t('ga4z.list.channels'), sub: t('ga4z.list.channels_sub') }), 'Wat zit erin: de kanalen. Een klik zoomt in op het kanaal (Herkomst).'); if (tab === 'conversions') { L.title = t('ga4n.conv.channels'); } } if (tab === 'sources' && lv === 1) { if (q.channel === 'Direct') { L = this.listWhy(angular.extend(this.list('main', rows, landRow), { title: t('ga4z.list.landing'), sub: t('ga4w.direct_sub') }), 'Direct heeft geen bron om in te zoomen: meteen de pagina\'s waar ze binnenkomen. Direct op diepe pagina\'s is meestal een link zonder tags (apps, WhatsApp, mail).'); } else { L = this.list('main', rows, function (r, tot) { return self.row(r, r.key, { tab: 'sources', path: { channel: q.channel, source: r.key, page: q.page } }, REF.indexOf(q.channel) > -1 ? t('ga4z.next.refpages') : t('ga4z.next.pages'), tot, avg); }); angular.extend(L, { title: t('ga4z.list.sources', { c: q.channel }), sub: t('ga4z.list.sources_sub') }); this.listWhy(L, 'Wat zit erin: de bronnen in ' + q.channel + '.'); // één bron is (bijna) alles: dit niveau overslaan, meteen de pagina's if (rows && L.tot && L.rows.length) { var topShare = L.rows[0].share; if (topShare >= MIN.dominant) { v.dominant = { label: L.rows[0].label, share: f.pct(topShare, 0), act: L.rows[0].act, others: L.rows.slice(1, 5).map(function (r) { return { label: r.label, num: r.num, act: r.act }; }) }; v.dominant.why = [L.rows[0].label + ' is ' + f.pct(topShare, 1) + ' van ' + q.channel + ' (drempel ' + MIN.dominant + '%): de lijst met bronnen zou één regel zijn. Daarom meteen de pagina\'s; de andere bronnen in één regel.']; v.skipped.push({ name: 'Bronnen in ' + q.channel, why: 'Overgeslagen: ' + L.rows[0].label + ' is ' + f.pct(topShare, 0) + '.' }); v.main = null; this.promote = true; return; } } } } if (tab === 'sources' && lv === 2) { if (this.isRef()) { L = this.refList(rows); } else { L = this.listWhy(angular.extend(this.list('main', rows, landRow), { title: t('ga4z.list.landing'), sub: t('ga4z.list.landing_sub') }), 'Wat zit erin: bij ' + q.source + ' de pagina\'s waar ze binnenkomen (GA4 kent geen zoektermen).'); } } if (tab === 'pages') { if (lv === 'page') { if (q.source) { v.skipped.push({ name: 'Waar komt het bezoek vandaan', why: 'Je komt van ' + q.source + ': alle sessies hier komen daarvandaan.' }); } else if (q.channel) { L = this.listWhy(angular.extend(this.list('main', rows, function (r, tot) { return self.row(r, r.key, { tab: 'sources', path: { channel: q.channel, source: r.key, page: q.page } }, '', tot, avg); }), { title: t('ga4z.list.page_channels'), sub: '' }), 'Je komt van ' + q.channel + ': welke bronnen daarin naar deze pagina sturen.'); } else { L = this.listWhy(angular.extend(this.list('main', rows, chRow), { title: t('ga4z.list.page_channels'), sub: t('ga4z.list.page_channels_sub') }), 'Waarmee hangt het samen: waar het bezoek van deze pagina vandaan komt.'); } } else { L = this.list('main', rows, function (r, tot) { return r.kind === 'folder' ? self.row(r, r.key, { tab: 'pages', path: { folder: r.key } }, t('ga4z.folder_pages', { n: n(r.pages) }), tot, avg) : self.row(r, r.key, { tab: 'pages', path: { page: r.key } }, '', tot, avg); }); angular.extend(L, { title: q.folder ? t('ga4z.list.folder', { f: q.folder }) : t('ga4z.list.root'), sub: t('ga4z.list.tree_sub') }); this.listWhy(L, 'Wat zit erin: mappen (opgeteld) en losse pagina\'s, op landingspagina.'); } } if (tab === 'audience') { var dim = this.audienceDim(), deep = DEEPER[q.dim || 'country'], canDeeper = deep && !q[deep.filter] && dim === (q.dim || 'country'); L = this.list('main', rows && rows.rows, function (r, tot) { var label = dim === 'deviceCategory' ? t('ga4n.device.' + r.key) : r.key, p = { dim: q.dim }, ok = canDeeper && (r.cur.sessions || 0) >= MIN.deeper; if (ok) { p[deep.filter] = r.key; } return self.row(r, label, ok ? { tab: 'audience', path: p } : null, ok ? t('ga4z.next.' + deep.dim) : '', tot, avg); }); angular.extend(L, { title: t('ga4z.list.audience.' + dim), sub: '' }); this.listWhy(L, 'Publiek gaat vooral over "is het goed": de markeringen stuitert / blijft vergelijken met het gemiddelde hier.'); if (canDeeper) { L.why.push('Inzoomen (' + t('ga4z.next.' + deep.dim) + ') alleen bij ' + MIN.deeper + ' sessies of meer; daaronder is een lijst steden of browsers ruis.'); } } v.main = L; }, // de pagina's van een site die naar je linken, met per regel of ze bleven refList: function (rows) { var self = this, avg = this.rowAvg, out = { title: t('ga4z.list.refs', { s: this.q.source }), sub: t('ga4z.list.refs_sub'), gone: [], why: [] }; if (rows === undefined) { return angular.extend(out, { loading: true, rows: [] }); } var bare = function (x) { return String(x || '').toLowerCase().replace(/^(www|m|l|lm|mobile)\./, ''); }; var src = bare(String(this.q.source || '').split(' / ')[0]); var all = rows || []; var mine = all.filter(function (r) { var hst = bare(r.host); return hst === src || hst.slice(-src.length - 1) === '.' + src || src.slice(-hst.length - 1) === '.' + hst; }); var tot = mine.reduce(function (a, r) { return a + (r.cur[self.prim] || 0); }, 0), lim = this.more.main || LIMIT; angular.extend(out, { total: mine.length, limit: lim, rows: mine.slice(0, lim).map(function (r) { var x = self.row({ key: r.ref, cur: r.cur, prev: r.prev }, r.ref.replace(/^https?:\/\/(www\.)?/, ''), { tab: 'pages', path: self.withOrigin({ page: r.page }) }, '→ ' + r.page, tot, avg); x.href = r.ref; return x; }) }); this.listWhy(out, 'Bij één verwijzer: welke pagina\'s op die site naar je linken, waar ze uitkwamen, en per regel de betrokkenheid (stuiteren of blijven ze).'); if (all.length > mine.length) { out.why.push((all.length - mine.length) + ' verwijzers van andere sites weggelaten: GA4 legt de verwijzer per paginaweergave vast, niet per bezoek, dus die horen niet bij deze bron.'); } return out; }, // vraag 5: waarmee hangt het samen (de andere as), compact connBlock: function (v, rows, lv) { var self = this, q = this.q, tab = this.tab, avg = this.rowAvg; if (!this.has.conn) { return; } var title, reason, mk; var land = function (r, tot) { return self.row(r, r.key, { tab: 'pages', path: self.withOrigin({ page: r.key }) }, '', tot, avg); }; if (tab === 'pages') { title = t('ga4w.conn.channels'); reason = 'Waarmee hangt het samen: waar het bezoek van deze map vandaan komt.'; mk = function (r, tot) { return self.row(r, r.key, { tab: 'sources', path: { channel: r.key } }, '', tot, avg); }; } else if (tab === 'conversions') { title = t('ga4n.conv.pages'); reason = 'Welke pagina\'s converteren: gesorteerd op key events.'; mk = land; } else { title = t('ga4z.side.landing'); mk = land; reason = tab === 'audience' ? 'Waarmee hangt het samen: waar ' + (this.q.country || this.q.device) + ' binnenkomt; stuitert = slechter dan gemiddeld hier.' : 'Waarmee hangt het samen: op welke pagina\'s ze binnenkomen.'; } if (this.promote) { // het niveau erboven is overgeslagen: de pagina's worden de hoofdlijst var L = this.listWhy(angular.extend(this.list('main', rows, land), { title: t('ga4z.list.landing'), sub: t('ga4z.list.landing_sub') }), 'De bronnen zijn overgeslagen (zie boven), dus dit is de hoofdlijst: de pagina\'s van heel ' + q.channel + '.'); v.main = L; this.promote = false; return; } var C = this.list('conn', rows, mk); if (tab === 'conversions' && C.rows) { C.rows = (rows || []).filter(function (r) { return (r.cur.key_events || 0) > 0; }).sort(function (a, b) { return (b.cur.key_events || 0) - (a.cur.key_events || 0); }).slice(0, LIMIT).map(function (r) { return land(r, 0); }); C.total = C.rows.length; } if (!C.loading && C.total < MIN.connRows) { v.skipped.push({ name: title, why: C.total + ' regel(s): onder de ' + MIN.connRows + ', dan zegt een lijst niets.' }); return; } C.title = title; C.why = [reason, 'Smal (pagina, getal, verschil, betrokkenheid): het is de bijvraag, de hoofdlijst staat links.']; v.conn = C; }, // het eind van elke route: een pagina; wat ze daarna lazen nextBlock: function (v, rows) { if (this.tab !== 'pages' || !this.q.page) { return; } var own = this.q.page.replace(/\/$/, '') || '/'; if (rows === undefined) { v.next = { loading: true, rows: [], why: [] }; return; } var list = (rows || []).filter(function (r) { return (r.key.replace(/\/$/, '') || '/') !== own && (r.cur.views || 0) > 0; }).slice(0, 10); if (!list.length) { v.skipped.push({ name: 'Daarna bekeken', why: 'Niemand bekeek in deze sessies nog een andere pagina.' }); return; } v.next = { rows: list.map(function (r) { return { label: r.key, num: n(r.cur.views), act: { tab: 'pages', path: { page: r.key } } }; }), why: ['Het eind van de route is altijd een pagina: blijven ze en wat lezen ze daarna. Weergaven van andere pagina\'s in de sessies met deze pagina (hooguit 10).'] }; }, convBlocks: function (v, events) { if (events === undefined) { v.events = null; return; } v.events = { rows: (events || []).slice(0, 25), why: ['Alle gebeurtenissen, om te zien wat als key event telt en wat niet.'] }; if (!v.measures.key_events && !v.measures.revenue) { v.skipped.push({ name: 'Conversies per kanaal en pagina, tegels en grafiek', why: 'De site meet geen key events of opbrengst: sessies staan al op Overzicht, hier alleen de gebeurtenissen.' }); v.main = null; v.conn = null; v.tiles = null; v.chart = null; } } }; $scope.$on('$routeUpdate', function () { c.readUrl(); c.tabs.forEach(function (x) { x.href = BASE + SLUG[x.key] + c.qs(); }); if (c.snapshot() !== c.loaded) { c.load(); } }); c.readUrl(); c.init(); }]);