Introduced new layout to bring new Dashboard closer to release (#2342)

refs: https://github.com/TryGhost/Team/issues/1531

- broke apart the combined chart
- added back in the paid mix chart
- separated out the mini charts into separate components
- made top chart work with total, paid and free
- added in an overview section back at the top for total, paid, free
- made metric labels and values larger and easier to parse

Co-authored-by: Simon Backx <simon@ghost.org>
This commit is contained in:
James Morris 2022-04-20 13:43:11 +01:00 committed by GitHub
parent 79eea318a9
commit a06003e7b5
18 changed files with 935 additions and 354 deletions

View File

@ -3,23 +3,14 @@
<GhLoadingSpinner />
{{else}}
{{#if this.areMembersEnabled}}
{{#if this.hasPaidTiers}}
<Dashboard::V5::Charts::Overview />
{{/if}}
<Dashboard::V5::Charts::Anchor />
{{/if}}
{{!-- Could these if else statements be cleaned up more? --}}
{{#if this.areNewslettersEnabled}}
<Dashboard::V5::Charts::Email />
<Dashboard::V5::Charts::Engagement />
<Dashboard::V5::Charts::EmailOpenRate />
{{else}}
<Dashboard::V5::Charts::Engagement />
{{/if}}
{{else}}
{{#if this.areNewslettersEnabled}}
<Dashboard::V5::Charts::Email />
<Dashboard::V5::Charts::Engagement />
<Dashboard::V5::Charts::EmailOpenRate />
{{/if}}
{{#if this.areNewslettersEnabled}}
<Dashboard::V5::Charts::Engagement />
{{/if}}
<Dashboard::V5::Charts::RecentPosts />

View File

@ -1,38 +1,28 @@
<section class="gh-dashboard5-section gh-dashboard5-anchor" {{did-insert this.loadCharts}}>
<article class="gh-dashboard5-box">
{{#if this.hasPaidTiers}}
<div class="gh-dashboard5-title">
<h4>{{this.chartTitle}}</h4>
</div>
<Dashboard::v5::Parts::Metric
@label="Members over time" />
{{else}}
<Dashboard::v5::Parts::Metric
@label="Total members"
@value={{format-number this.totalMembers}}
@trends={{this.hasTrends}}
@percentage={{this.totalMembersTrend}}
@large={{true}} />
{{/if}}
<div class="gh-dashboard5-hero {{unless this.hasPaidTiers 'is-solo'}}">
{{#unless this.hasPaidTiers}}
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.totalMembers "Total member" without-count=true}}
@value={{format-number this.totalMembers}}
@trends={{this.hasTrends}}
@percentage={{this.totalMembersTrend}}
@large={{true}} />
{{/unless}}
<div class="gh-dashboard5-hero {{unless this.hasPaidTiers 'is-solo'}}">
<div class="gh-dashboard5-chart">
{{#if this.loading}}
<div class="gh-dashboard5-chart-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<div class="gh-dashboard5-chart-container">
{{#if (eq this.chartType 'bar')}}
<EmberChart
@type="bar"
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{if this.hasPaidTiers this.chartHeight this.chartHeightSmall}} />
{{else}}
<EmberChart
@type="line"
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{if this.hasPaidTiers this.chartHeight this.chartHeightSmall}} />
{{/if}}
<EmberChart
@type="line"
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{if this.hasPaidTiers this.chartHeight this.chartHeightSmall}} />
</div>
<div class="gh-dashboard5-chart-ticks">
<span id="gh-dashboard5-anchor-date-start"> </span>
@ -42,46 +32,28 @@
</div>
{{#if this.hasPaidTiers}}
<div class="gh-dashboard5-stats{{unless this.hasPaidTiers ' is-solo'}}">
<button class="gh-dashboard5-stats-button {{if this.chartShowingTotal 'is-selected'}}" type="button" {{on "click" (fn this.changeChartDisplay "total")}}>
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.totalMembers "Total member" without-count=true}}
@value={{format-number this.totalMembers}}
{{!-- @trends={{this.hasTrends}} --}}
{{!-- @percentage={{this.totalMembersTrend}} --}}
@center={{true}}
@large={{true}} />
</button>
<button class="gh-dashboard5-stats-button {{if this.chartShowingPaid 'is-selected'}}" type="button" {{on "click" (fn this.changeChartDisplay "paid-total")}}>
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.paidMembers "Total paid member" without-count=true}}
@value={{format-number this.paidMembers}}
{{!-- @trends={{this.hasTrends}} --}}
{{!-- @percentage={{this.paidMembersTrend}} --}}
@center={{true}}
@large={{true}} />
</button>
<button class="gh-dashboard5-stats-button {{if this.chartShowingMrr 'is-selected'}}" type="button" {{on "click" (fn this.changeChartDisplay "mrr")}}>
<Dashboard::v5::Parts::Metric
@label="Monthly Revenue (MRR)"
@value="{{this.mrrCurrencySymbol}}{{gh-price-amount this.currentMRR}}"
{{!-- @trends={{this.hasTrends}} --}}
{{!-- @percentage={{this.mrrTrend}} --}}
@center={{true}}
@large={{true}} />
</button>
</div>
<article class="gh-dashboard5-columns">
<div class="gh-dashboard5-column gh-dashboard5-mrr">
<Dashboard::v5::Charts::Mrr />
</div>
<div class="gh-dashboard5-column gh-dashboard5-breakdown">
<Dashboard::v5::Charts::PaidBreakdown />
</div>
<div class="gh-dashboard5-column gh-dashboard5-mix">
<Dashboard::v5::Charts::PaidMix />
</div>
</article>
{{/if}}
</div>
<div class="gh-dashboard5-selects">
{{#if this.chartShowingPaid}}
{{#if this.hasPaidTiers}}
<div class="gh-dashboard5-select">
<PowerSelect
@selected={{this.selectedPaidOption}}
@options={{this.paidOptions}}
@selected={{this.selectedDisplayOption}}
@options={{this.displayOptions}}
@searchEnabled={{false}}
@onChange={{this.onPaidChange}}
@onChange={{this.onDisplayChange}}
@triggerComponent="gh-power-select/trigger"
@triggerClass="gh-contentfilter-menu-trigger"
@dropdownClass="gh-contentfilter-menu-dropdown"
@ -92,6 +64,7 @@
</PowerSelect>
</div>
{{/if}}
<div class="gh-dashboard5-select">
<PowerSelect
@selected={{this.selectedDaysOption}}

View File

@ -22,12 +22,15 @@ const DAYS_OPTIONS = [{
value: 'all'
}];
const PAID_OPTIONS = [{
name: 'Total',
value: 'paid-total'
const DISPLAY_OPTIONS = [{
name: 'All members',
value: 'total'
}, {
name: 'By Day',
value: 'paid-breakdown'
name: 'Paid members',
value: 'paid'
}, {
name: 'Free members',
value: 'free'
}];
export default class Anchor extends Component {
@ -36,7 +39,7 @@ export default class Anchor extends Component {
@tracked chartDisplay = 'total';
daysOptions = DAYS_OPTIONS;
paidOptions = PAID_OPTIONS;
displayOptions = DISPLAY_OPTIONS;
get days() {
return this.dashboardStats.chartDays;
@ -46,29 +49,18 @@ export default class Anchor extends Component {
this.dashboardStats.chartDays = days;
}
@action
onInsert() {
this.dashboardStats.loadSiteStatus();
}
@action
loadCharts() {
this.dashboardStats.loadMemberCountStats();
this.dashboardStats.loadMrrStats();
}
@action
changeChartDisplay(type) {
this.chartDisplay = type;
if (this.hasPaidTiers) {
this.dashboardStats.loadMrrStats();
}
}
@action
onPaidChange(selected) {
this.changeChartDisplay(selected.value);
// The graph won't switch correctly from line -> bar
// So we need to recreate it somehow.
// Solution: recreate the DOM by using an #if in hbs
onDisplayChange(selected) {
this.chartDisplay = selected.value;
}
@action
@ -80,33 +72,12 @@ export default class Anchor extends Component {
return this.daysOptions.find(d => d.value === this.days);
}
get selectedPaidOption() {
return this.paidOptions.find(d => d.value === this.chartDisplay) ?? this.paidOptions[0];
}
get chartShowingTotal() {
return (this.chartDisplay === 'total');
}
get chartShowingPaid() {
return (this.chartDisplay === 'paid-total' || this.chartDisplay === 'paid-breakdown');
}
get chartShowingMrr() {
return (this.chartDisplay === 'mrr');
get selectedDisplayOption() {
return this.displayOptions.find(d => d.value === this.chartDisplay) ?? this.displayOptions[0];
}
get loading() {
if (this.chartDisplay === 'total') {
return this.dashboardStats.memberCountStats === null;
} else if (this.chartDisplay === 'paid-total') {
return this.dashboardStats.memberCountStats === null;
} else if (this.chartDisplay === 'paid-breakdown') {
return this.dashboardStats.memberCountStats === null;
} else if (this.chartDisplay === 'mrr') {
return this.dashboardStats.mrrStats === null;
}
return true;
return this.dashboardStats.memberCountStats === null;
}
get totalMembers() {
@ -121,15 +92,9 @@ export default class Anchor extends Component {
return this.dashboardStats.memberCounts?.free ?? 0;
}
get currentMRR() {
return this.dashboardStats.currentMRR ?? 0;
}
get hasTrends() {
return this.dashboardStats.memberCounts !== null
&& this.dashboardStats.memberCountsTrend !== null
&& this.dashboardStats.currentMRR !== null
&& this.dashboardStats.currentMRRTrend !== null;
&& this.dashboardStats.memberCountsTrend !== null;
}
get totalMembersTrend() {
@ -144,92 +109,29 @@ export default class Anchor extends Component {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.free, this.dashboardStats.memberCounts.free);
}
get mrrTrend() {
return this.calculatePercentage(this.dashboardStats.currentMRRTrend, this.dashboardStats.currentMRR);
}
get hasPaidTiers() {
return this.dashboardStats.siteStatus?.hasPaidTiers;
}
get chartTitle() {
if (this.chartDisplay === 'paid-total') {
return 'Total paid members';
} else if (this.chartDisplay === 'paid-breakdown') {
return 'Paid members by day';
} else if (this.chartDisplay === 'mrr') {
return 'Monthly revenue (MRR)';
}
return 'Total members';
}
get chartType() {
if (this.chartDisplay === 'paid-breakdown') {
return 'bar';
}
return 'line';
}
get chartData() {
if (this.chartDisplay === 'paid-breakdown') {
const stats = this.dashboardStats.filledMemberCountStats;
const labels = stats.map(stat => stat.date);
const newData = stats.map(stat => stat.paidSubscribed);
const canceledData = stats.map(stat => -stat.paidCanceled);
const netData = stats.map(stat => stat.paidSubscribed - stat.paidCanceled);
const barThickness = (this.selectedDaysOption.value < 90 ? 18 : 7);
return {
labels: labels,
datasets: [
{
type: 'line',
data: netData,
tension: 0,
cubicInterpolationMode: 'monotone',
fill: false,
pointRadius: 0,
pointHitRadius: 10,
pointBorderColor: '#14B8FF',
pointBackgroundColor: '#14B8FF',
pointHoverBackgroundColor: '#14B8FF',
pointHoverBorderColor: '#14B8FF',
pointHoverRadius: 0,
borderColor: 'rgba(189, 197, 204, 0.5)',
borderJoinStyle: 'miter',
borderWidth: 3
}, {
data: newData,
fill: false,
backgroundColor: '#BD96F6',
cubicInterpolationMode: 'monotone',
barThickness: barThickness,
minBarLength: 3
}, {
data: canceledData,
fill: false,
backgroundColor: '#FB76B4',
cubicInterpolationMode: 'monotone',
barThickness: barThickness,
minBarLength: 3
}]
};
}
let stats;
let labels;
let data;
if (this.chartDisplay === 'paid-total') {
// paid-total
if (this.chartDisplay === 'paid') {
// paid
stats = this.dashboardStats.filledMemberCountStats;
labels = stats.map(stat => stat.date);
data = stats.map(stat => stat.paid);
} else if (this.chartDisplay === 'mrr') {
// mrr
stats = this.dashboardStats.filledMrrStats;
data = stats.map(stat => stat.paid + stat.comped);
} else if (this.chartDisplay === 'free') {
// free
stats = this.dashboardStats.filledMemberCountStats;
labels = stats.map(stat => stat.date);
data = stats.map(stat => stat.mrr);
data = stats.map(stat => stat.free);
} else {
// total
stats = this.dashboardStats.filledMemberCountStats;
@ -244,16 +146,16 @@ export default class Anchor extends Component {
tension: 0,
cubicInterpolationMode: 'monotone',
fill: true,
fillColor: 'rgba(20, 184, 255, 0.07)',
backgroundColor: 'rgba(20, 184, 255, 0.07)',
fillColor: 'rgba(142, 66, 255, 0.05)',
backgroundColor: 'rgba(142, 66, 255, 0.05)',
pointRadius: 0,
pointHitRadius: 10,
pointBorderColor: '#14B8FF',
pointBackgroundColor: '#14B8FF',
pointHoverBackgroundColor: '#14B8FF',
pointHoverBorderColor: '#14B8FF',
pointBorderColor: '#8E42FF',
pointBackgroundColor: '#8E42FF',
pointHoverBackgroundColor: '#8E42FF',
pointHoverBorderColor: '#8E42FF',
pointHoverRadius: 0,
borderColor: '#14B8FF',
borderColor: '#8E42FF',
borderJoinStyle: 'miter'
}]
};
@ -270,107 +172,6 @@ export default class Anchor extends Component {
get chartOptions() {
let barColor = this.feature.nightShift ? 'rgba(200, 204, 217, 0.25)' : 'rgba(200, 204, 217, 0.65)';
if (this.chartDisplay === 'paid-breakdown') {
return {
responsive: true,
maintainAspectRatio: false,
title: {
display: false
},
legend: {
display: false
},
hover: {
onHover: function (e) {
e.target.style.cursor = 'pointer';
}
},
tooltips: {
intersect: false,
mode: 'index',
displayColors: false,
backgroundColor: '#15171A',
xPadding: 7,
yPadding: 7,
cornerRadius: 5,
caretSize: 7,
caretPadding: 5,
bodyFontSize: 12.5,
titleFontSize: 12,
titleFontStyle: 'normal',
titleFontColor: 'rgba(255, 255, 255, 0.7)',
titleMarginBottom: 3,
callbacks: {
label: (tooltipItems, data) => {
let valueText = data.datasets[tooltipItems.datasetIndex].data[tooltipItems.index].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
if (tooltipItems.datasetIndex === 0) {
return `Net: ${valueText}`;
}
if (tooltipItems.datasetIndex === 1) {
return `New paid: ${valueText}`;
}
if (tooltipItems.datasetIndex === 2) {
return `Canceled paid: ${Math.abs(valueText)}`;
}
},
title: (tooltipItems) => {
return moment(tooltipItems[0].xLabel).format(DATE_FORMAT);
}
}
},
scales: {
yAxes: [{
offset: false,
gridLines: {
drawTicks: false,
display: true,
drawBorder: false,
color: 'rgba(255, 255, 255, 0.1)',
lineWidth: 0,
zeroLineColor: barColor,
zeroLineWidth: 1
},
ticks: {
display: false,
maxTicksLimit: 5,
fontColor: '#7C8B9A',
padding: 8,
precision: 0
}
}],
xAxes: [{
offset: true,
stacked: true,
gridLines: {
color: barColor,
borderDash: [4,4],
display: true,
drawBorder: false,
drawTicks: false,
zeroLineWidth: 1,
zeroLineColor: barColor,
zeroLineBorderDash: [4,4]
},
ticks: {
display: false,
callback: function (value, index, values) {
if (index === 0) {
document.getElementById('gh-dashboard5-anchor-date-start').innerHTML = moment(value).format(DATE_FORMAT);
}
if (index === values.length - 1) {
document.getElementById('gh-dashboard5-anchor-date-end').innerHTML = moment(value).format(DATE_FORMAT);
}
return value;
}
}
}]
}
};
}
return {
responsive: true,
@ -482,11 +283,11 @@ export default class Anchor extends Component {
}
get chartHeight() {
return 250;
return 200;
}
get chartHeightSmall() {
return 225;
return 200;
}
calculatePercentage(from, to) {

View File

@ -1,16 +1,28 @@
<section class="gh-dashboard5-section gh-dashboard5-engagement">
<article {{did-insert this.loadCharts}} class="gh-dashboard5-box">
<div class="gh-dashboard5-rows">
<div class="gh-dashboard5-row">
<Dashboard::v5::Parts::Metric
@label="Engagement" />
<div class="gh-dashboard5-columns">
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label="Engaged over 30 days"
@label="Engaged in the last 30 days"
@value={{this.data30Days}}
@secondary={{true}}
/>
</div>
<div class="gh-dashboard5-row">
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label="Engaged over 7 days"
@label="Engaged in the last 7 days"
@value={{this.data7Days}}
@secondary={{true}}
/>
</div>
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label="Newsletter subscribers"
@value={{this.dataSubscribers}}
@secondary={{true}}
/>
</div>
</div>

View File

@ -1,16 +1,17 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {formatNumber} from '../../../../helpers/format-number';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
const STATUS_OPTIONS = [{
name: 'All',
name: 'All members',
value: 'total'
}, {
name: 'Paid',
name: 'Paid members',
value: 'paid'
}, {
name: 'Free',
name: 'Free members',
value: 'free'
}];
@ -22,6 +23,7 @@ export default class Engagement extends Component {
this.dashboardStats.lastSeenFilterStatus = this.status;
this.dashboardStats.loadLastSeen();
this.dashboardStats.loadMemberCountStats();
this.dashboardStats.loadNewsletterSubscribers();
}
@tracked status = 'total';
@ -74,4 +76,16 @@ export default class Engagement extends Component {
const percentage = Math.round(part / total * 100);
return `${percentage}%`;
}
get dataSubscribers() {
if (!this.dashboardStats.newsletterSubscribers) {
return '-';
}
return formatNumber(this.dashboardStats.newsletterSubscribers[this.status]);
}
get dataEmailsSent() {
return this.dashboardStats.emailsSent30d ?? 0;
}
}

View File

@ -0,0 +1,17 @@
<Dashboard::v5::Parts::Metric
@label={{this.chartTitle}}
@value="{{this.currentMRRFormatted}}"
@trends={{this.hasTrends}}
@percentage={{this.mrrTrend}} />
<div class="gh-dashboard5-chart">
{{#if this.loading}}
<div class="gh-dashboard5-chart-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}
</div>

View File

@ -0,0 +1,185 @@
import Component from '@glimmer/component';
import moment from 'moment';
import {getSymbol} from 'ghost-admin/utils/currency';
import {ghPriceAmount} from '../../../../helpers/gh-price-amount';
import {inject as service} from '@ember/service';
const DATE_FORMAT = 'D MMM';
export default class Mrr extends Component {
@service dashboardStats;
@service feature;
get loading() {
return this.dashboardStats.mrrStats === null;
}
get currentMRR() {
return this.dashboardStats.currentMRR ?? 0;
}
get mrrTrend() {
return this.calculatePercentage(this.dashboardStats.currentMRRTrend, this.dashboardStats.currentMRR);
}
get hasTrends() {
return this.dashboardStats.currentMRR !== null
&& this.dashboardStats.currentMRRTrend !== null;
}
get chartTitle() {
return 'MRR';
}
get chartType() {
return 'line';
}
get mrrCurrencySymbol() {
if (this.dashboardStats.mrrStats === null) {
return '';
}
const firstCurrency = this.dashboardStats.mrrStats[0] ? this.dashboardStats.mrrStats[0].currency : 'usd';
return getSymbol(firstCurrency);
}
get currentMRRFormatted() {
if (this.dashboardStats.mrrStats === null) {
return '-';
}
const valueText = ghPriceAmount(this.currentMRR);
return `${this.mrrCurrencySymbol}${valueText}`;
}
get chartData() {
const stats = this.dashboardStats.filledMrrStats;
const labels = stats.map(stat => stat.date);
const data = stats.map(stat => stat.mrr);
return {
labels: labels,
datasets: [{
data: data,
tension: 0,
cubicInterpolationMode: 'monotone',
fill: true,
fillColor: 'rgba(142, 66, 255, 0.02)',
backgroundColor: 'rgba(142, 66, 255, 0.02)',
pointRadius: 0,
pointHitRadius: 10,
pointBorderColor: '#8E42FF',
pointBackgroundColor: '#8E42FF',
pointHoverBackgroundColor: '#8E42FF',
pointHoverBorderColor: '#8E42FF',
pointHoverRadius: 0,
borderColor: '#8E42FF',
borderJoinStyle: 'miter'
}]
};
}
get chartOptions() {
let barColor = this.feature.nightShift ? 'rgba(200, 204, 217, 0.25)' : 'rgba(200, 204, 217, 0.65)';
return {
responsive: true,
maintainAspectRatio: false,
title: {
display: false
},
legend: {
display: false
},
layout: {
padding: {
top: 2,
bottom: 2
}
},
hover: {
onHover: function (e) {
e.target.style.cursor = 'pointer';
}
},
tooltips: {
intersect: false,
mode: 'index',
displayColors: false,
backgroundColor: '#15171A',
xPadding: 7,
yPadding: 7,
cornerRadius: 5,
caretSize: 7,
caretPadding: 5,
bodyFontSize: 12.5,
titleFontSize: 12,
titleFontStyle: 'normal',
titleFontColor: 'rgba(255, 255, 255, 0.7)',
titleMarginBottom: 3,
callbacks: {
label: (tooltipItems, data) => {
// Convert integer in cents to value in USD/other currency.
const valueText = ghPriceAmount(data.datasets[tooltipItems.datasetIndex].data[tooltipItems.index]);
return `MRR: ${this.mrrCurrencySymbol}${valueText}`;
},
title: (tooltipItems) => {
return moment(tooltipItems[0].xLabel).format(DATE_FORMAT);
}
}
},
scales: {
yAxes: [{
display: true,
gridLines: {
drawTicks: false,
display: false,
drawBorder: false,
color: 'transparent',
zeroLineColor: barColor,
zeroLineWidth: 1
},
ticks: {
display: false
}
}],
xAxes: [{
display: true,
scaleLabel: {
align: 'start'
},
gridLines: {
color: barColor,
borderDash: [4,4],
display: false,
drawBorder: true,
drawTicks: false,
zeroLineWidth: 1,
zeroLineColor: barColor,
zeroLineBorderDash: [4,4]
},
ticks: {
display: false,
beginAtZero: true
}
}]
}
};
}
get chartHeight() {
return 90;
}
calculatePercentage(from, to) {
if (from === 0) {
if (to > 0) {
return 100;
}
return 0;
}
return Math.round((to - from) / from * 100);
}
}

View File

@ -0,0 +1,32 @@
<section class="gh-dashboard5-section gh-dashboard5-overview">
<article {{did-insert this.loadCharts}} class="gh-dashboard5-box is-secondary">
<div class="gh-dashboard5-columns">
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.totalMembers "Total member" without-count=true}}
@value={{this.totalMembersFormatted}}
@trends={{this.hasTrends}}
@percentage={{this.totalMembersTrend}}
@large={{true}} />
</div>
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.paidMembers "Paid member" without-count=true}}
@value={{this.paidMembersFormatted}}
@trends={{this.hasTrends}}
@percentage={{this.paidMembersTrend}}
@large={{true}} />
</div>
<div class="gh-dashboard5-column">
<Dashboard::v5::Parts::Metric
@label={{gh-pluralize this.freeMembers "Free member" without-count=true}}
@value={{this.freeMembersFormatted}}
@trends={{this.hasTrends}}
@percentage={{this.freeMembersTrend}}
@large={{true}} />
</div>
</div>
</article>
</section>

View File

@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {formatNumber} from '../../../../helpers/format-number';
import {inject as service} from '@ember/service';
export default class Overview extends Component {
@service dashboardStats;
@service feature;
@action
loadCharts() {
this.dashboardStats.loadMemberCountStats();
}
get loading() {
return this.dashboardStats.memberCountStats === null;
}
get totalMembers() {
return this.dashboardStats.memberCounts?.total ?? 0;
}
get paidMembers() {
return this.dashboardStats.memberCounts?.paid ?? 0;
}
get freeMembers() {
return this.dashboardStats.memberCounts?.free ?? 0;
}
get totalMembersFormatted() {
if (this.dashboardStats.memberCounts === null) {
return '-';
}
return formatNumber(this.totalMembers);
}
get paidMembersFormatted() {
if (this.dashboardStats.memberCounts === null) {
return '-';
}
return formatNumber(this.paidMembers);
}
get freeMembersFormatted() {
if (this.dashboardStats.memberCounts === null) {
return '-';
}
return formatNumber(this.freeMembers);
}
get hasTrends() {
return this.dashboardStats.memberCounts !== null
&& this.dashboardStats.memberCountsTrend !== null
&& this.dashboardStats.currentMRR !== null
&& this.dashboardStats.currentMRRTrend !== null;
}
get totalMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.total, this.dashboardStats.memberCounts.total);
}
get paidMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.paid, this.dashboardStats.memberCounts.paid);
}
get freeMembersTrend() {
return this.calculatePercentage(this.dashboardStats.memberCountsTrend.free, this.dashboardStats.memberCounts.free);
}
get hasPaidTiers() {
return this.dashboardStats.siteStatus?.hasPaidTiers;
}
calculatePercentage(from, to) {
if (from === 0) {
if (to > 0) {
return 100;
}
return 0;
}
return Math.round((to - from) / from * 100);
}
}

View File

@ -0,0 +1,15 @@
<Dashboard::v5::Parts::Metric
@label={{this.chartTitle}} />
<div class="gh-dashboard5-chart" {{did-insert this.loadCharts}}>
{{#if this.loading}}
<div class="gh-dashboard5-chart-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
{{/if}}
</div>

View File

@ -0,0 +1,172 @@
import Component from '@glimmer/component';
import moment from 'moment';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
const DATE_FORMAT = 'D MMM';
export default class PaidBreakdown extends Component {
@service dashboardStats;
@service feature;
@action
loadCharts() {
// todo: load the new data here
}
get loading() {
return this.dashboardStats.memberCountStats === null;
}
get chartTitle() {
return 'Paid subscribers';
}
get chartType() {
return 'bar';
}
get chartData() {
const stats = this.dashboardStats.filledMemberCountStats;
const labels = stats.map(stat => stat.date);
const newData = stats.map(stat => stat.paidSubscribed);
const canceledData = stats.map(stat => -stat.paidCanceled);
const netData = stats.map(stat => stat.paidSubscribed - stat.paidCanceled);
// const barThickness = (this.daysSelected.value < 90 ? 18 : 7);
const barThickness = 4;
return {
labels: labels,
datasets: [
{
type: 'line',
data: netData,
tension: 0,
cubicInterpolationMode: 'monotone',
fill: false,
pointRadius: 0,
pointHitRadius: 10,
pointBorderColor: '#14B8FF',
pointBackgroundColor: '#14B8FF',
pointHoverBackgroundColor: '#14B8FF',
pointHoverBorderColor: '#14B8FF',
pointHoverRadius: 0,
borderColor: 'rgba(189, 197, 204, 0.5)',
borderJoinStyle: 'miter',
borderWidth: 3
}, {
data: newData,
fill: false,
backgroundColor: '#8E42FF',
cubicInterpolationMode: 'monotone',
barThickness: barThickness,
minBarLength: 3
}, {
data: canceledData,
fill: false,
backgroundColor: '#FB76B4',
cubicInterpolationMode: 'monotone',
barThickness: barThickness,
minBarLength: 3
}]
};
}
get chartOptions() {
let barColor = this.feature.nightShift ? 'rgba(200, 204, 217, 0.25)' : 'rgba(200, 204, 217, 0.65)';
return {
responsive: true,
maintainAspectRatio: false,
title: {
display: false
},
legend: {
display: false
},
hover: {
onHover: function (e) {
e.target.style.cursor = 'pointer';
}
},
tooltips: {
intersect: false,
mode: 'index',
displayColors: false,
backgroundColor: '#15171A',
xPadding: 7,
yPadding: 7,
cornerRadius: 5,
caretSize: 7,
caretPadding: 5,
bodyFontSize: 12.5,
titleFontSize: 12,
titleFontStyle: 'normal',
titleFontColor: 'rgba(255, 255, 255, 0.7)',
titleMarginBottom: 3,
callbacks: {
label: (tooltipItems, data) => {
let valueText = data.datasets[tooltipItems.datasetIndex].data[tooltipItems.index].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
if (tooltipItems.datasetIndex === 0) {
return `Net: ${valueText}`;
}
if (tooltipItems.datasetIndex === 1) {
return `New paid: ${valueText}`;
}
if (tooltipItems.datasetIndex === 2) {
return `Canceled paid: ${Math.abs(valueText)}`;
}
},
title: (tooltipItems) => {
return moment(tooltipItems[0].xLabel).format(DATE_FORMAT);
}
}
},
scales: {
yAxes: [{
offset: false,
gridLines: {
drawTicks: false,
display: true,
drawBorder: false,
color: 'rgba(255, 255, 255, 0.1)',
lineWidth: 0,
zeroLineColor: barColor,
zeroLineWidth: 1
},
ticks: {
display: false,
maxTicksLimit: 5,
fontColor: '#7C8B9A',
padding: 8,
precision: 0
}
}],
xAxes: [{
offset: true,
stacked: true,
gridLines: {
color: barColor,
borderDash: [4,4],
display: false,
drawBorder: false,
drawTicks: false,
zeroLineWidth: 1,
zeroLineColor: barColor,
zeroLineBorderDash: [4,4]
},
ticks: {
display: false
}
}]
}
};
}
get chartHeight() {
return 100;
}
}

View File

@ -0,0 +1,34 @@
{{#if this.hasMultipleTiers }}
<div class="gh-dashboard5-select">
<PowerSelect
@selected={{this.selectedModeOption}}
@options={{this.modeOptions}}
@searchEnabled={{false}}
@onChange={{this.onSwitchMode}}
@triggerComponent="gh-power-select/trigger"
@triggerClass="gh-contentfilter-menu-trigger"
@dropdownClass="gh-contentfilter-menu-dropdown"
@matchTriggerWidth={{false}}
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
{{/if}}
<Dashboard::v5::Parts::Metric
@label="Paid mix" />
<div class="gh-dashboard5-chart" {{did-insert this.loadCharts}}>
{{#if this.loading}}
<div class="gh-dashboard5-chart-loading" style={{html-safe (concat "height: " this.chartHeight "px;")}}/>
{{else}}
<div class="gh-dashboard5-chart-container">
<EmberChart
@type={{this.chartType}}
@data={{this.chartData}}
@options={{this.chartOptions}}
@height={{this.chartHeight}} />
</div>
{{/if}}
</div>

View File

@ -0,0 +1,151 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
const MODE_OPTIONS = [{
name: 'Cadence',
value: 'cadence'
}, {
name: 'Tiers',
value: 'tiers'
}];
export default class PaidMix extends Component {
@service dashboardStats;
/**
* Call this method when you need to fetch new data from the server.
*/
@action
loadCharts() {
// The dashboard stats service will take care or reusing and limiting API-requests between charts
if (this.mode === 'cadence') {
this.dashboardStats.loadPaidMembersByCadence();
} else {
this.dashboardStats.loadPaidMembersByTier();
}
}
@tracked mode = 'cadence';
modeOptions = MODE_OPTIONS;
get selectedModeOption() {
return this.modeOptions.find(option => option.value === this.mode);
}
get hasMultipleTiers() {
return this.dashboardStats.siteStatus?.hasMultipleTiers;
}
@action
onSwitchMode(selected) {
this.mode = selected.value;
if (this.loading) {
// We don't have the data yet for the newly selected mode
this.loadCharts();
}
}
get loading() {
if (this.mode === 'cadence') {
return this.dashboardStats.paidMembersByCadence === null;
}
return this.dashboardStats.paidMembersByTier === null;
}
get chartType() {
return 'horizontalBar';
}
get chartData() {
if (this.mode === 'cadence') {
return {
labels: ['Monthly', 'Annual'],
datasets: [{
data: [this.dashboardStats.paidMembersByCadence.monthly, this.dashboardStats.paidMembersByCadence.annual],
fill: false,
backgroundColor: ['#8E42FF', '#FB76B4', '#E9B0CC', '#F1D5E3'],
barThickness: 7
}]
};
}
const labels = this.dashboardStats.paidMembersByTier.map(stat => stat.tier.name);
const data = this.dashboardStats.paidMembersByTier.map(stat => stat.members);
return {
labels,
datasets: [{
data,
fill: false,
backgroundColor: ['#8E42FF', '#FB76B4', '#E9B0CC', '#F1D5E3'],
barThickness: 7
}]
};
}
get chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
cutoutPercentage: (this.mode === 'cadence' ? 85 : 75),
legend: {
display: false
},
animation: {
duration: 0
},
hover: {
onHover: function (e) {
e.target.style.cursor = 'pointer';
}
},
tooltips: {
intersect: false,
mode: 'index',
displayColors: false,
backgroundColor: '#15171A',
xPadding: 7,
yPadding: 7,
cornerRadius: 5,
caretSize: 7,
caretPadding: 5,
bodyFontSize: 12.5,
titleFontSize: 12,
titleFontStyle: 'normal',
titleFontColor: 'rgba(255, 255, 255, 0.7)',
titleMarginBottom: 3
},
scales: {
yAxes: [{
gridLines: {
display: true,
drawBorder: false,
color: 'transparent',
lineWidth: 0,
zeroLineColor: 'transparent',
zeroLineWidth: 1
},
ticks: {
display: true
}
}],
xAxes: [{
stacked: true,
gridLines: {
display: false
},
ticks: {
display: false
}
}]
}
};
}
get chartHeight() {
return 75;
}
}

View File

@ -1,28 +1,42 @@
<div class="gh-dashboard5-metric {{if @center "is-center"}} {{if @reverse "is-reverse"}} {{if @large "is-large"}}">
<div class="gh-dashboard5-metric-data">
{{#if @reverse}}
{{#if @secondary}}
{{#if @value}}
<div class="gh-dashboard5-metric-value">
<div class="gh-dashboard5-metric-value {{if @secondary 'is-secondary'}}">
{{@value}}
{{#if @trends}}
<Dashboard::v5::Parts::Percentage @percentage={{@percentage}}/>
{{/if}}
</div>
{{/if}}
<h5 class="gh-dashboard5-metric-label">
<h5 class="gh-dashboard5-metric-label {{if @secondary 'is-secondary'}}">
{{@label}}
</h5>
{{else}}
<h5 class="gh-dashboard5-metric-label">
{{@label}}
</h5>
{{#if @value}}
<div class="gh-dashboard5-metric-value">
{{@value}}
{{#if @trends}}
{{#if @reverse}}
{{#if @value}}
<div class="gh-dashboard5-metric-value">
{{@value}}
{{#if @trends}}
<Dashboard::v5::Parts::Percentage @percentage={{@percentage}}/>
{{/if}}
</div>
</div>
{{/if}}
<h5 class="gh-dashboard5-metric-label">
{{@label}}
</h5>
{{else}}
<h5 class="gh-dashboard5-metric-label">
{{@label}}
</h5>
{{#if @value}}
<div class="gh-dashboard5-metric-value">
{{@value}}
{{#if @trends}}
<Dashboard::v5::Parts::Percentage @percentage={{@percentage}}/>
{{/if}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>

View File

@ -387,7 +387,8 @@ export default class DashboardMocksService extends Service {
this.mrrStats = stats.map((s) => {
return {
date: s.date,
mrr: s.tier1 * 500 + s.tier2 * 2500
mrr: s.tier1 * 500 + s.tier2 * 2500,
currency: 'usd'
};
});
}

View File

@ -502,8 +502,8 @@ export default class DashboardStatsService extends Service {
}
const [paid, free] = yield Promise.all([
this.membersCountCache.count('subscribed:true+status:paid'),
this.membersCountCache.count('subscribed:true+status:-paid')
this.membersCountCache.count('subscribed:true+status:-free'),
this.membersCountCache.count('subscribed:true+status:free')
]);
this.newsletterSubscribers = {

View File

@ -1121,3 +1121,11 @@ kbd {
.gh-dashboard5 .gh-dashboard5-list-footer {
border-color: #394047;
}
.gh-dashboard5 .gh-dashboard5-box.is-secondary {
background: transparent;
}
.gh-dashboard5 .gh-dashboard5-column {
border-color: #2b2d31;
}

View File

@ -68,12 +68,13 @@ Dashboard v5 Layout */
.gh-dashboard5-layout {
display: grid;
grid-gap: 32px;
grid-gap: 24px;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: auto auto auto auto;
grid-template-rows: auto auto auto auto auto;
grid-template-areas:
"overview overview overview overview"
"anchor anchor anchor anchor"
"email engagement email-open-rate email-open-rate"
"engagement engagement engagement engagement"
"recent-posts recent-posts recent-activity recent-activity"
"help help help help";
}
@ -118,13 +119,43 @@ Dashboard v5 Layout */
flex: 1;
border: 1px solid var(--whitegrey);
padding: 24px;
border-radius: 3px;
border-radius: 6px;
display: flex;
flex-direction: column;
position: relative;
align-items: stretch;
}
.gh-dashboard5-box.is-secondary {
background: #fcfcfc;
}
.gh-dashboard5-columns {
flex: 1;
padding: 0;
display: flex;
flex-direction: row;
position: relative;
align-items: stretch;
background: transparent;
}
.gh-dashboard5-columns > .gh-dashboard5-column {
flex: 1;
border-left: 1px solid var(--whitegrey);
padding: 4px 24px;
position: relative;
}
.gh-dashboard5-columns > .gh-dashboard5-column:first-child {
padding-left: 0;
border-left: 0 none;
}
.gh-dashboard5-columns > .gh-dashboard5-column:last-child {
padding-right: 0;
}
.gh-dashboard5-hero {
flex: 1;
display: flex;
@ -163,8 +194,7 @@ Dashboard v5 Layout */
}
.gh-dashboard5-select .ember-power-select-selected-item {
font-size: 1.1rem;
text-transform: uppercase;
font-size: 1.2rem;
font-weight: 600;
letter-spacing: .3px;
line-height: 1em;
@ -266,25 +296,14 @@ Dashboard v5 Metric */
.gh-dashboard5-metric-label {
align-items: center;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: .2px;
font-weight: 500;
letter-spacing: .3px;
font-size: 1.4rem;
font-weight: 700;
line-height: 1em;
margin: 0 0 12px;
margin: 0 0 10px;
padding: 0;
color: var(--middarkgrey);
color: var(--black);
white-space: nowrap;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: .2px;
font-weight: 500;
}
.gh-dashboard5-metric.is-large .gh-dashboard5-metric-label {
margin-bottom: 10px;
}
.gh-dashboard5-metric.is-reverse .gh-dashboard5-metric-label {
@ -296,11 +315,17 @@ Dashboard v5 Metric */
margin-top: 4px;
}
.gh-dashboard5-metric-label.is-secondary {
font-size: 1.3rem;
font-weight: 500;
color: var(--middarkgrey);
}
.gh-dashboard5-metric-value {
display: flex;
align-items: flex-end;
font-size: 2.4rem;
font-weight: 600;
font-size: 2.6rem;
font-weight: 700;
letter-spacing: -.1px;
line-height: 1em;
white-space: nowrap;
@ -309,8 +334,7 @@ Dashboard v5 Metric */
}
.gh-dashboard5-metric.is-large .gh-dashboard5-metric-value {
font-size: 3rem;
font-weight: 600;
font-size: 3.2rem;
margin-bottom: 0;
}
@ -322,6 +346,10 @@ Dashboard v5 Metric */
margin-bottom: 8px;
}
.gh-dashboard5-metric-value.is-secondary {
margin: 0 0 8px;
}
.gh-dashboard5-metric-extra {
text-transform: none;
font-weight: 500;
@ -430,6 +458,31 @@ Dashboard v5 List */
}
/* ---------------------------------
Dashboard v5 Section Overview */
.gh-dashboard5-overview {
grid-area: overview;
}
.gh-dashboard5-overview .gh-dashboard5-area {
flex-direction: row;
padding: 24px;
}
.gh-dashboard5-overview .gh-dashboard5-area > div {
flex: 1;
border-right: 1px solid var(--whitegrey);
padding-top: 4px;
padding-bottom: 4px;
padding-left: 24px;
}
.gh-dashboard5-overview .gh-dashboard5-area > div:first-child {
padding-left: 0;
}
/* ---------------------------------
Dashboard v5 Section Anchor */
@ -439,7 +492,7 @@ Dashboard v5 Section Anchor */
}
.gh-dashboard5-anchor .gh-dashboard5-box {
padding: 20px 24px 8px;
padding: 28px 24px 8px;
}
.gh-dashboard5-anchor .gh-dashboard5-stats {
@ -507,6 +560,7 @@ Dashboard v5 Section Anchor */
.gh-dashboard5-anchor .gh-dashboard5-chart {
flex-direction: column;
margin-top: 24px;
}
.gh-dashboard5-anchor .gh-dashboard5-chart-ticks {
@ -516,6 +570,9 @@ Dashboard v5 Section Anchor */
color: var(--midlightgrey);
}
.gh-dashboard5-anchor .gh-dashboard5-columns {
margin-top: 48px;
}
/* ---------------------------------
Dashboard v5 Section Engagement */
@ -529,6 +586,10 @@ Dashboard v5 Section Engagement */
padding-top: 28px;
}
.gh-dashboard5-engagement .gh-dashboard5-columns {
padding-top: 32px;
}
/* ---------------------------------
Dashboard v5 Section Email */
@ -679,3 +740,18 @@ Dashboard v5 Misc */
.gh-dashboard5 .gh-list-header {
border-bottom: 0;
}
.gh-dashboard5-anchor .gh-dashboard5-mix .gh-dashboard5-select {
top: -11px;
right: -18px;
}
.gh-dashboard5-anchor .gh-dashboard5-mix .gh-dashboard5-chart {
margin-top: 40px;
}
.gh-dashboard5-anchor .gh-dashboard5-mrr .gh-dashboard5-chart {
margin-top: 0;
}