Start creating content
+ {{#if this.showMembersData}} +Create your first member
+Add yourself or import members from CSV
+Publish a post
+Get familiar with the Ghost editor and start creating
+diff --git a/ghost/admin/app/controllers/dashboard-labs.js b/ghost/admin/app/controllers/dashboard-labs.js
new file mode 100644
index 0000000000..e8e2dea699
--- /dev/null
+++ b/ghost/admin/app/controllers/dashboard-labs.js
@@ -0,0 +1,240 @@
+import Controller from '@ember/controller';
+import {action} from '@ember/object';
+import {getSymbol} from 'ghost-admin/utils/currency';
+import {inject as service} from '@ember/service';
+import {tracked} from '@glimmer/tracking';
+
+export default class DashboardController extends Controller {
+ @service feature;
+ @service session;
+ @service membersStats;
+ @service store;
+ @service settings;
+ @service whatsNew;
+
+ @tracked eventsData = null;
+ @tracked eventsError = null;
+ @tracked eventsLoading = false;
+
+ @tracked mrrStatsData = null;
+ @tracked mrrStatsError = null;
+ @tracked mrrStatsLoading = false;
+
+ @tracked memberCountStatsData = null;
+ @tracked memberCountStatsError = null;
+ @tracked memberCountStatsLoading = false;
+
+ @tracked topMembersData = null;
+ @tracked topMembersError = null;
+ @tracked topMembersLoading = false;
+
+ @tracked newsletterOpenRatesData = null;
+ @tracked newsletterOpenRatesError = null;
+ @tracked newsletterOpenRatesLoading = false;
+
+ @tracked whatsNewEntries = null;
+ @tracked whatsNewEntriesLoading = null;
+ @tracked whatsNewEntriesError = null;
+
+ get topMembersDataHasOpenRates() {
+ return this.topMembersData && this.topMembersData.find((member) => {
+ return member.emailOpenRate !== null;
+ });
+ }
+
+ get showMembersData() {
+ return this.settings.get('membersSignupAccess') !== 'none';
+ }
+
+ initialise() {
+ this.loadEvents();
+ this.loadTopMembers();
+ this.loadCharts();
+ this.loadWhatsNew();
+ }
+
+ async loadMRRStats() {
+ const products = await this.store.query('product', {include: 'monthly_price,yearly_price', limit: 'all'});
+ const defaultProduct = products?.firstObject;
+
+ this.mrrStatsLoading = true;
+ this.membersStats.fetchMRR().then((stats) => {
+ this.mrrStatsLoading = false;
+ const statsData = stats.data || [];
+ const defaultCurrency = defaultProduct?.monthlyPrice?.currency || 'usd';
+ let currencyStats = statsData.find((stat) => {
+ return stat.currency === defaultCurrency;
+ });
+ currencyStats = currencyStats || {
+ data: [],
+ currency: defaultCurrency
+ };
+ if (currencyStats) {
+ const currencyStatsData = this.membersStats.fillDates(currencyStats.data) || {};
+ const dateValues = Object.values(currencyStatsData).map(val => Math.round((val / 100)));
+ const currentMRR = dateValues.length ? dateValues[dateValues.length - 1] : 0;
+ const rangeStartMRR = dateValues.length ? dateValues[0] : 0;
+ const percentGrowth = rangeStartMRR !== 0 ? ((currentMRR - rangeStartMRR) / rangeStartMRR) * 100 : 0;
+ this.mrrStatsData = {
+ currentAmount: currentMRR,
+ currency: getSymbol(currencyStats.currency),
+ percentGrowth: percentGrowth.toFixed(1),
+ percentClass: (percentGrowth > 0 ? 'positive' : (percentGrowth < 0 ? 'negative' : '')),
+ options: {
+ rangeInDays: 30
+ },
+ data: {
+ label: 'MRR',
+ dateLabels: Object.keys(currencyStatsData),
+ dateValues
+ },
+ title: 'MRR',
+ stats: currencyStats
+ };
+ }
+ }, (error) => {
+ this.mrrStatsError = error;
+ this.mrrStatsLoading = false;
+ });
+ }
+
+ loadMemberCountStats() {
+ this.memberCountStatsLoading = true;
+ this.membersStats.fetchCounts().then((stats) => {
+ this.memberCountStatsLoading = false;
+
+ if (stats) {
+ const statsDateObj = this.membersStats.fillCountDates(stats.data) || {};
+ const dateValues = Object.values(statsDateObj);
+ const currentAllCount = dateValues.length ? dateValues[dateValues.length - 1].total : 0;
+ const currentPaidCount = dateValues.length ? dateValues[dateValues.length - 1].paid : 0;
+ const rangeStartAllCount = dateValues.length ? dateValues[0].total : 0;
+ const rangeStartPaidCount = dateValues.length ? dateValues[0].paid : 0;
+ const allCountPercentGrowth = rangeStartAllCount !== 0 ? ((currentAllCount - rangeStartAllCount) / rangeStartAllCount) * 100 : 0;
+ const paidCountPercentGrowth = rangeStartPaidCount !== 0 ? ((currentPaidCount - rangeStartPaidCount) / rangeStartPaidCount) * 100 : 0;
+
+ this.memberCountStatsData = {
+ all: {
+ percentGrowth: allCountPercentGrowth.toFixed(1),
+ percentClass: (allCountPercentGrowth > 0 ? 'positive' : (allCountPercentGrowth < 0 ? 'negative' : '')),
+ total: dateValues.length ? dateValues[dateValues.length - 1].total : 0,
+ options: {
+ rangeInDays: 30
+ },
+ data: {
+ label: 'Members',
+ dateLabels: Object.keys(statsDateObj),
+ dateValues: dateValues.map(d => d.total)
+ },
+ title: 'Total Members',
+ stats: stats
+ },
+ paid: {
+ percentGrowth: paidCountPercentGrowth.toFixed(1),
+ percentClass: (paidCountPercentGrowth > 0 ? 'positive' : (paidCountPercentGrowth < 0 ? 'negative' : '')),
+ total: dateValues.length ? dateValues[dateValues.length - 1].paid : 0,
+ options: {
+ rangeInDays: 30
+ },
+ data: {
+ label: 'Members',
+ dateLabels: Object.keys(statsDateObj),
+ dateValues: dateValues.map(d => d.paid)
+ },
+ title: 'Paid Members',
+ stats: stats
+ }
+ };
+ }
+ }, (error) => {
+ this.memberCountStatsError = error;
+ this.memberCountStatsLoading = false;
+ });
+ }
+
+ loadCharts() {
+ this.loadMRRStats();
+ this.loadMemberCountStats();
+ this.loadNewsletterOpenRates();
+ }
+
+ loadEvents() {
+ this.eventsLoading = true;
+ this.membersStats.fetchTimeline({limit: 5}).then(({events}) => {
+ this.eventsData = events;
+ this.eventsLoading = false;
+ }, (error) => {
+ this.eventsError = error;
+ this.eventsLoading = false;
+ });
+ }
+
+ loadNewsletterOpenRates() {
+ this.newsletterOpenRatesLoading = true;
+ this.membersStats.fetchNewsletterStats().then((results) => {
+ const rangeStartOpenRate = results.length > 1 ? results[results.length - 2].openRate : 0;
+ const rangeEndOpenRate = results.length > 0 ? results[results.length - 1].openRate : 0;
+ const percentGrowth = rangeStartOpenRate !== 0 ? ((rangeEndOpenRate - rangeStartOpenRate) / rangeStartOpenRate) * 100 : 0;
+ this.newsletterOpenRatesData = {
+ percentGrowth: percentGrowth.toFixed(1),
+ percentClass: (percentGrowth > 0 ? 'positive' : (percentGrowth < 0 ? 'negative' : '')),
+ current: rangeEndOpenRate,
+ options: {
+ rangeInDays: 30
+ },
+ data: {
+ label: 'Open rate',
+ dateLabels: results.map(d => d.subject),
+ dateValues: results.map(d => d.openRate)
+ },
+ title: 'Open rate',
+ stats: results
+ };
+ this.newsletterOpenRatesLoading = false;
+ }, (error) => {
+ this.newsletterOpenRatesError = error;
+ this.newsletterOpenRatesLoading = false;
+ });
+ }
+
+ loadTopMembers() {
+ this.topMembersLoading = true;
+ let query = {
+ filter: 'email_open_rate:-null',
+ order: 'email_open_rate desc',
+ limit: 5
+ };
+ this.store.query('member', query).then((result) => {
+ if (!result.length) {
+ return this.store.query('member', {
+ filter: 'status:paid',
+ order: 'created_at asc',
+ limit: 5
+ });
+ }
+ return result;
+ }).then((result) => {
+ this.topMembersData = result;
+ this.topMembersLoading = false;
+ }).catch((error) => {
+ this.topMembersError = error;
+ this.topMembersLoading = false;
+ });
+ }
+
+ loadWhatsNew() {
+ this.whatsNewEntriesLoading = true;
+ this.whatsNew.fetchLatest.perform().then(() => {
+ this.whatsNewEntriesLoading = false;
+ this.whatsNewEntries = this.whatsNew.entries.slice(0, 3);
+ }, (error) => {
+ this.whatsNewEntriesError = error;
+ this.whatsNewEntriesLoading = false;
+ });
+ }
+
+ @action
+ dismissLaunchBanner() {
+ this.feature.set('launchComplete', true);
+ }
+}
diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js
index 17ddd8760f..3b681a638c 100644
--- a/ghost/admin/app/router.js
+++ b/ghost/admin/app/router.js
@@ -24,6 +24,7 @@ Router.map(function () {
this.route('about');
this.route('site');
this.route('dashboard');
+ this.route('dashboard-labs');
this.route('launch');
this.route('pro', function () {
diff --git a/ghost/admin/app/routes/dashboard-labs.js b/ghost/admin/app/routes/dashboard-labs.js
new file mode 100644
index 0000000000..2421c38b7a
--- /dev/null
+++ b/ghost/admin/app/routes/dashboard-labs.js
@@ -0,0 +1,28 @@
+import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
+import {inject as service} from '@ember/service';
+
+export default class DashboardRoute extends AuthenticatedRoute {
+ @service feature;
+
+ beforeModel() {
+ super.beforeModel(...arguments);
+
+ if (!this.session.user.isAdmin) {
+ return this.transitionTo('site');
+ }
+
+ if (!this.feature.dashboardTwo) {
+ return this.transitionTo('dashboard');
+ }
+ }
+
+ buildRouteInfoMetadata() {
+ return {
+ mainClasses: ['gh-main-wide']
+ };
+ }
+
+ setupController() {
+ this.controller.initialise();
+ }
+}
diff --git a/ghost/admin/app/routes/dashboard.js b/ghost/admin/app/routes/dashboard.js
index aa6a76652e..ce67ab1c87 100644
--- a/ghost/admin/app/routes/dashboard.js
+++ b/ghost/admin/app/routes/dashboard.js
@@ -1,11 +1,19 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
+import {inject as service} from '@ember/service';
export default class DashboardRoute extends AuthenticatedRoute {
+ @service feature;
+
beforeModel() {
super.beforeModel(...arguments);
+
if (!this.session.user.isAdmin) {
return this.transitionTo('site');
}
+
+ if (this.feature.dashboardTwo) {
+ return this.transitionTo('dashboard-labs');
+ }
}
buildRouteInfoMetadata() {
diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js
index 1b8c05f0f8..d6bb80135b 100644
--- a/ghost/admin/app/services/feature.js
+++ b/ghost/admin/app/services/feature.js
@@ -61,6 +61,7 @@ export default Service.extend({
oauthLogin: feature('oauthLogin', {developer: true}),
checkEmailList: feature('checkEmailList', {developer: true}),
emailOnlyPosts: feature('emailOnlyPosts', {developer: true}),
+ dashboardTwo: feature('dashboardTwo', {developer: true}),
_user: null,
diff --git a/ghost/admin/app/templates/dashboard-labs.hbs b/ghost/admin/app/templates/dashboard-labs.hbs
new file mode 100644
index 0000000000..f260528d02
--- /dev/null
+++ b/ghost/admin/app/templates/dashboard-labs.hbs
@@ -0,0 +1,330 @@
+
+ There was an error loading MRR
+
+ There was an error loading total members
+
+ There was an error loading paid members
+ Add yourself or import members from CSV Get familiar with the Ghost editor and start creating Stand out from the crowd. Ghost lets you customize everything so you can create a publication that doesn’t just look the same as what everyone else has. Our product knowledgebase is packed full of guides, tutorials, answers to frequently asked questions, tips for dealing with common errors, and much more. Choosing one of these newsletter types for your publication will help you create better content at a faster pace with less work. Get some inspiration → The creator economy is growing faster than ever, and so are we! 📈 Join a team that's determined to make decentralised, open technology the heart and soul of new media 🌺 Starting from zero is hard. Thankfully, successful creators have given us clues on how to grow an audience by using something called a content funnel. Here's how it works → Meet other people building both free & paid publications with Ghost. Talk strategy, get advice, or just hang out.
+ There was an error loading member events.
+
+ There was an error loading events
+ {{entry.custom_excerpt}}
+ Dashboard
+
+ MRR
+ 30 days
+ {{this.mrrStatsError.message}}
+ {{this.memberCountStatsData.message}}
+ Total members
+ {{this.memberCountStatsData.message}}
+ Paid members
+ Start creating content
+ {{#if this.showMembersData}}
+ Create your first member
+ Publish a post
+ Customize your site design
+ Looking for help with Ghost features?
+ 6 types of newsletters you can start today
+ We're hiring! Join the team that makes Ghost.
+ How to grow your audience, starting from 0
+ Join the Ghost creator community.
+ Top members
+ {{#if this.topMembersDataHasOpenRates}}
+ Open rate
+ {{else}}
+ Member since
+ {{/if}}
+ {{this.events.error.message}}
+
+ {{#each this.topMembersData as |member|}}
+
+ {{/if}}
+ {{/if}}
+
+ Activity feed
+ {{this.eventsError.message}}
+ What's new?
+ {{svg-jar "gift"}}
+ {{entry.title}}
+ {{moment-format entry.published_at "D MMM YYYY"}}
+ {{#if entry.custom_excerpt}}
+