1afe96ae34
ref DES-709 - when refresh button is clicked, the numbers in the analytics will be animated if changed - for the animation to be performant, added a new dependency "animejs" - to minimize the flash and layout shift, the analytics data is kept as it is while loading - once finished loading, it will be replaced with the new data
448 lines
13 KiB
JavaScript
448 lines
13 KiB
JavaScript
import Component from '@glimmer/component';
|
|
import DeletePostModal from '../modals/delete-post';
|
|
import PostSuccessModal from '../modal-post-success';
|
|
import anime from 'animejs/lib/anime.es.js';
|
|
import {action} from '@ember/object';
|
|
import {didCancel, task} from 'ember-concurrency';
|
|
import {inject as service} from '@ember/service';
|
|
import {tracked} from '@glimmer/tracking';
|
|
|
|
/**
|
|
* @typedef {import('../../services/dashboard-stats').SourceAttributionCount} SourceAttributionCount
|
|
*/
|
|
|
|
const DISPLAY_OPTIONS = [{
|
|
name: 'Free signups',
|
|
value: 'signups'
|
|
}, {
|
|
name: 'Paid conversions',
|
|
value: 'paid'
|
|
}];
|
|
|
|
export default class Analytics extends Component {
|
|
@service ajax;
|
|
@service ghostPaths;
|
|
@service settings;
|
|
@service membersUtils;
|
|
@service utils;
|
|
@service feature;
|
|
@service store;
|
|
@service router;
|
|
@service modals;
|
|
@service notifications;
|
|
|
|
@tracked sources = null;
|
|
@tracked links = null;
|
|
@tracked mentions = null;
|
|
@tracked sortColumn = 'signups';
|
|
@tracked showSuccess;
|
|
@tracked updateLinkId;
|
|
@tracked _post = null;
|
|
@tracked postCount = null;
|
|
@tracked showPostCount = false;
|
|
@tracked shouldAnimate = false;
|
|
@tracked previousSentCount = this.post.email.emailCount;
|
|
@tracked previousOpenedCount = this.post.email.openedCount;
|
|
@tracked previousClickedCount = this.post.count.clicks;
|
|
@tracked previousFeedbackCount = this.totalFeedback;
|
|
@tracked previousConversionsCount = this.post.count.conversions;
|
|
displayOptions = DISPLAY_OPTIONS;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
if (this.feature.publishFlowEndScreen) {
|
|
this.checkPublishFlowModal();
|
|
}
|
|
}
|
|
|
|
openPublishFlowModal() {
|
|
this.modals.open(PostSuccessModal, {
|
|
post: this.post,
|
|
postCount: this.postCount,
|
|
showPostCount: this.showPostCount
|
|
});
|
|
}
|
|
|
|
async checkPublishFlowModal() {
|
|
if (localStorage.getItem('ghost-last-published-post')) {
|
|
await this.fetchPostCountTask.perform();
|
|
this.showPostCount = true;
|
|
this.openPublishFlowModal();
|
|
localStorage.removeItem('ghost-last-published-post');
|
|
}
|
|
}
|
|
|
|
get post() {
|
|
if (this.feature.publishFlowEndScreen) {
|
|
return this._post ?? this.args.post;
|
|
}
|
|
|
|
return this.args.post;
|
|
}
|
|
|
|
set post(value) {
|
|
this._post = value;
|
|
}
|
|
|
|
get allowedDisplayOptions() {
|
|
if (!this.hasPaidConversionData) {
|
|
return this.displayOptions.filter(d => d.value === 'signups');
|
|
}
|
|
|
|
if (!this.hasFreeSignups) {
|
|
return this.displayOptions.filter(d => d.value === 'paid');
|
|
}
|
|
|
|
return this.displayOptions;
|
|
}
|
|
|
|
get isDropdownDisabled() {
|
|
if (!this.hasPaidConversionData || !this.hasFreeSignups) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
get selectedDisplayOption() {
|
|
if (!this.hasPaidConversionData) {
|
|
return this.displayOptions.find(d => d.value === 'signups');
|
|
}
|
|
|
|
if (!this.hasFreeSignups) {
|
|
return this.displayOptions.find(d => d.value === 'paid');
|
|
}
|
|
|
|
return this.displayOptions.find(d => d.value === this.sortColumn) ?? this.displayOptions[0];
|
|
}
|
|
|
|
get selectedSortColumn() {
|
|
if (!this.hasPaidConversionData) {
|
|
return 'signups';
|
|
}
|
|
|
|
if (!this.hasFreeSignups) {
|
|
return 'paid';
|
|
}
|
|
return this.sortColumn;
|
|
}
|
|
|
|
get hasPaidConversionData() {
|
|
return this.sources.some(sourceData => sourceData.paidConversions > 0);
|
|
}
|
|
|
|
get hasFreeSignups() {
|
|
return this.sources.some(sourceData => sourceData.signups > 0);
|
|
}
|
|
|
|
get totalFeedback() {
|
|
return this.post.count.positive_feedback + this.post.count.negative_feedback;
|
|
}
|
|
|
|
get feedbackChartData() {
|
|
const values = [this.post.count.positive_feedback, this.post.count.negative_feedback];
|
|
const labels = ['More like this', 'Less like this'];
|
|
const links = [
|
|
{filterParam: '(feedback.post_id:\'' + this.post.id + '\'+feedback.score:1)'},
|
|
{filterParam: '(feedback.post_id:\'' + this.post.id + '\'+feedback.score:0)'}
|
|
];
|
|
const colors = ['#F080B2', '#8452f633'];
|
|
return {values, labels, links, colors};
|
|
}
|
|
|
|
@action
|
|
onDisplayChange(selected) {
|
|
this.sortColumn = selected.value;
|
|
}
|
|
|
|
@action
|
|
setSortColumn(column) {
|
|
this.sortColumn = column;
|
|
}
|
|
|
|
@action
|
|
updateLink(linkId, linkTo) {
|
|
if (this._updateLinks.isRunning) {
|
|
return this._updateLinks.last;
|
|
}
|
|
return this._updateLinks.perform(linkId, linkTo);
|
|
}
|
|
|
|
@action
|
|
loadData() {
|
|
if (this.showSources) {
|
|
this.fetchReferrersStats();
|
|
} else {
|
|
this.sources = [];
|
|
}
|
|
|
|
if (this.showLinks) {
|
|
this.fetchLinks();
|
|
} else {
|
|
this.links = [];
|
|
}
|
|
|
|
if (this.showMentions) {
|
|
this.fetchMentions();
|
|
} else {
|
|
this.mentions = [];
|
|
}
|
|
}
|
|
|
|
@action
|
|
togglePublishFlowModal() {
|
|
this.showPostCount = false;
|
|
this.openPublishFlowModal();
|
|
}
|
|
|
|
@action
|
|
confirmDeleteMember() {
|
|
this.modals.open(DeletePostModal, {
|
|
post: this.post
|
|
});
|
|
}
|
|
|
|
updateLinkData(linksData) {
|
|
let updatedLinks;
|
|
if (this.links?.length) {
|
|
updatedLinks = this.links.map((link) => {
|
|
let linkData = linksData.find(l => l.link.link_id === link.link.link_id);
|
|
if (linkData) {
|
|
return {
|
|
...linkData,
|
|
link: {
|
|
...linkData.link,
|
|
originalTo: linkData.link.to,
|
|
to: this.utils.cleanTrackedUrl(linkData.link.to, false),
|
|
title: this.utils.cleanTrackedUrl(linkData.link.to, true)
|
|
}
|
|
};
|
|
}
|
|
return link;
|
|
});
|
|
} else {
|
|
updatedLinks = linksData.map((link) => {
|
|
return {
|
|
...link,
|
|
link: {
|
|
...link.link,
|
|
originalTo: link.link.to,
|
|
to: this.utils.cleanTrackedUrl(link.link.to, false),
|
|
title: this.utils.cleanTrackedUrl(link.link.to, true)
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Remove duplicates by title ad merge
|
|
const linksByTitle = updatedLinks.reduce((acc, link) => {
|
|
if (!acc[link.link.title]) {
|
|
acc[link.link.title] = link;
|
|
} else {
|
|
if (!acc[link.link.title].count) {
|
|
acc[link.link.title].count = {clicks: 0};
|
|
}
|
|
if (!acc[link.link.title].count.clicks) {
|
|
acc[link.link.title].count.clicks = 0;
|
|
}
|
|
|
|
acc[link.link.title].count.clicks += (link.count?.clicks ?? 0);
|
|
}
|
|
return acc;
|
|
}, {});
|
|
|
|
this.links = Object.values(linksByTitle);
|
|
}
|
|
|
|
async fetchReferrersStats() {
|
|
try {
|
|
if (this._fetchReferrersStats.isRunning) {
|
|
return this._fetchReferrersStats.last;
|
|
}
|
|
return this._fetchReferrersStats.perform();
|
|
} catch (e) {
|
|
// Do not throw cancellation errors
|
|
if (didCancel(e)) {
|
|
return;
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async fetchLinks() {
|
|
try {
|
|
if (this._fetchLinks.isRunning) {
|
|
return this._fetchLinks.last;
|
|
}
|
|
|
|
return this._fetchLinks.perform();
|
|
} catch (e) {
|
|
// Do not throw cancellation errors
|
|
if (didCancel(e)) {
|
|
return;
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@task
|
|
*_updateLinks(linkId, newLink) {
|
|
this.updateLinkId = linkId;
|
|
let currentLink;
|
|
this.links = this.links?.map((link) => {
|
|
if (link.link.link_id === linkId) {
|
|
currentLink = new URL(link.link.originalTo);
|
|
return {
|
|
...link,
|
|
link: {
|
|
...link.link,
|
|
to: this.utils.cleanTrackedUrl(newLink, false),
|
|
title: this.utils.cleanTrackedUrl(newLink, true)
|
|
}
|
|
};
|
|
}
|
|
return link;
|
|
});
|
|
|
|
const filter = `post_id:'${this.post.id}'+to:'${currentLink}'`;
|
|
let bulkUpdateUrl = this.ghostPaths.url.api(`links/bulk`) + `?filter=${encodeURIComponent(filter)}`;
|
|
yield this.ajax.put(bulkUpdateUrl, {
|
|
data: {
|
|
bulk: {
|
|
action: 'updateLink',
|
|
meta: {link: {to: newLink}}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Refresh links data
|
|
const linksFilter = `post_id:'${this.post.id}'`;
|
|
let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(linksFilter)}`;
|
|
let result = yield this.ajax.request(statsUrl);
|
|
this.updateLinkData(result.links);
|
|
this.showSuccess = this.updateLinkId;
|
|
setTimeout(() => {
|
|
this.showSuccess = null;
|
|
}, 2000);
|
|
}
|
|
|
|
@task
|
|
*_fetchReferrersStats() {
|
|
let statsUrl = this.ghostPaths.url.api(`stats/referrers/posts/${this.post.id}`);
|
|
let result = yield this.ajax.request(statsUrl);
|
|
this.sources = result.stats.map((stat) => {
|
|
return {
|
|
source: stat.source ?? 'Direct',
|
|
signups: stat.signups,
|
|
paidConversions: stat.paid_conversions
|
|
};
|
|
});
|
|
}
|
|
|
|
@task
|
|
*_fetchLinks() {
|
|
const filter = `post_id:'${this.post.id}'`;
|
|
let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(filter)}`;
|
|
let result = yield this.ajax.request(statsUrl);
|
|
this.updateLinkData(result.links);
|
|
}
|
|
|
|
async fetchMentions() {
|
|
if (this._fetchMentions.isRunning) {
|
|
return this._fetchMentions.last;
|
|
}
|
|
return this._fetchMentions.perform();
|
|
}
|
|
|
|
@task
|
|
*_fetchMentions() {
|
|
const filter = `resource_id:'${this.post.id}'+resource_type:post`;
|
|
this.mentions = yield this.store.query('mention', {limit: 5, order: 'created_at desc', filter});
|
|
}
|
|
|
|
@task
|
|
*fetchPostCountTask() {
|
|
if (!this.post.emailOnly) {
|
|
const result = yield this.store.query('post', {filter: 'status:published', limit: 1});
|
|
let count = result.meta.pagination.total;
|
|
|
|
this.postCount = count;
|
|
}
|
|
}
|
|
|
|
@task
|
|
*fetchPostTask() {
|
|
const currentSentCount = this.post.email.emailCount;
|
|
const currentOpenedCount = this.post.email.openedCount;
|
|
const currentClickedCount = this.post.count.clicks;
|
|
const currentFeedbackCount = this.totalFeedback;
|
|
const currentConversionsCount = this.post.count.conversions;
|
|
|
|
this.shouldAnimate = true;
|
|
|
|
const result = yield this.store.query('post', {filter: `id:${this.post.id}`, include: 'email,count.clicks,count.conversions,count.positive_feedback,count.negative_feedback', limit: 1});
|
|
this.post = result.toArray()[0];
|
|
|
|
this.previousSentCount = currentSentCount;
|
|
this.previousOpenedCount = currentOpenedCount;
|
|
this.previousClickedCount = currentClickedCount;
|
|
this.previousFeedbackCount = currentFeedbackCount;
|
|
this.previousConversionsCount = currentConversionsCount;
|
|
|
|
yield this.fetchLinks();
|
|
|
|
return true;
|
|
}
|
|
|
|
@action
|
|
applyClasses(element) {
|
|
if (!this.shouldAnimate ||
|
|
(element.classList.contains('sent') && this.post.email.emailCount === this.previousSentCount) ||
|
|
(element.classList.contains('opened') && this.post.email.openedCount === this.previousOpenedCount) ||
|
|
(element.classList.contains('clicked') && this.post.count.clicks === this.previousClickedCount) ||
|
|
(element.classList.contains('feedback') && this.totalFeedback === this.previousFeedbackCount) ||
|
|
(element.classList.contains('conversions') && this.post.count.conversions === this.previousConversionsCount)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
anime({
|
|
targets: `${Array.from(element.classList).map(className => `.${className}`).join('')} .new-number span`,
|
|
translateY: [10,0],
|
|
// translateZ: 0,
|
|
opacity: [0,1],
|
|
easing: 'easeOutElastic',
|
|
elasticity: 650,
|
|
duration: 1000,
|
|
delay: (el, i) => 100 + 30 * i
|
|
});
|
|
|
|
anime({
|
|
targets: `${Array.from(element.classList).map(className => `.${className}`).join('')} .old-number span`,
|
|
translateY: [0,-10],
|
|
opacity: [1,0],
|
|
easing: 'easeOutExpo',
|
|
duration: 400,
|
|
delay: (el, i) => 100 + 10 * i
|
|
});
|
|
}
|
|
|
|
get showLinks() {
|
|
return this.post.showEmailClickAnalytics;
|
|
}
|
|
|
|
get showSources() {
|
|
return this.post.showAttributionAnalytics;
|
|
}
|
|
|
|
get showMentions() {
|
|
return this.feature.get('webmentions');
|
|
}
|
|
|
|
get isLoaded() {
|
|
return this.links !== null && this.souces !== null && this.mentions !== null;
|
|
}
|
|
}
|