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 @@ +
+ +

+ Dashboard +

+
+ +
+ + {{#if (and this.session.user.isOwnerOnly (not this.feature.launchComplete))}} +
+
+

Select your publication style

+

Customize your brand and connect to Stripe to get your membership site ready to be shown to the world.

+ Start setup guide +
+ + + {{svg-jar "dotdotdot"}} + + + + + +
+
+
+ {{else if this.showMembersData}} +
+
+
+

MRR

+

30 days

+
+
+ {{#if this.mrrStatsLoading}} + Loading... + {{else}} + {{#if this.mrrStatsError}} +

+ There was an error loading MRR + {{this.mrrStatsError.message}} +

+ {{else}} +
+
{{this.mrrStatsData.currency}}{{format-number this.mrrStatsData.currentAmount}}
+
{{this.mrrStatsData.percentGrowth}}%
+
+ {{#if this.mrrStatsData}} +
+ +
+ {{/if}} + {{/if}} + {{/if}} +
+
+
+
+ {{#if this.memberCountStatsLoading}} + Loading... + {{else}} + {{#if this.memberCountStatsError}} +

+ There was an error loading total members + {{this.memberCountStatsData.message}} +

+ {{else}} +
+

Total members

+
+
{{format-number this.memberCountStatsData.all.total}}
+
{{this.memberCountStatsData.all.percentGrowth}}%
+
+
+
+ +
+ {{/if}} + {{/if}} +
+
+ + +
+ {{/if}} + + +
+ {{#if (not this.feature.launchComplete)}} +
+
+

Start creating content

+ {{#if this.showMembersData}} + + {{svg-jar "members"}} +
+

Create your first member

+

Add yourself or import members from CSV

+
+
+ {{/if}} + + {{svg-jar "posts"}} +
+

Publish a post

+

Get familiar with the Ghost editor and start creating

+
+
+
+
+ {{/if}} + +
+
+
+

Customize your site design

+

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.

+
+ +
+
+
+

Looking for help with Ghost features?

+

Our product knowledgebase is packed full of guides, tutorials, answers to frequently asked questions, tips for dealing with common errors, and much more.

+
+ +
+
+ + +
+
+

6 types of newsletters you can start today

+

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 →

+
5 MIN READ
+
+
+
+
+ + +
+
+

We're hiring! Join the team that makes Ghost.

+

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 🌺

+
+
+ See open roles → +
+
+
+ + +
+
+
+

How to grow your audience, starting from 0

+

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 →

+
9 MIN READ
+
+
+
+ +
+
+

Join the Ghost creator community.

+

Meet other people building both free & paid publications with Ghost. Talk strategy, get advice, or just hang out.

+ Share the journey +
+ community.ghost.org +
+
+ +
+ {{#if this.showMembersData}} + {{#if this.topMembersData}} +
+
+

Top members

+ {{#if this.topMembersDataHasOpenRates}} +

Open rate

+ {{else}} +

Member since

+ {{/if}} +
+
+ {{#if this.topMembersLoading}} + Loading... + {{else}} + {{#if this.topMembersError}} +

+ There was an error loading member events. + +

+ {{else}} +
    + {{#each this.topMembersData as |member|}} +
  • + + + {{#if member.name}} + {{member.name}} + {{else}} + + {{/if}} + + {{#if member.emailOpenRate}} + {{member.emailOpenRate}}% + {{else}} + + {{moment-format member.createdAtUTC "D MMM YYYY"}} + + {{/if}} +
  • + {{/each}} +
+ {{/if}} + {{/if}} + +
+
+ {{/if}} + + {{#unless (and this.session.user.isOwnerOnly (not this.feature.launchComplete))}} +
+

Activity feed

+
+ {{#if this.eventsLoading}} + Loading... + {{else}} + {{#if this.eventsError}} +

+ There was an error loading events + {{this.eventsError.message}} +

+ {{else}} + + {{/if}} + {{/if}} +
+
+ {{/unless}} + {{/if}} + + {{#unless (or whatsNewEntriesLoading whatsNewEntriesError)}} +
+
+

What's new?

+ {{svg-jar "gift"}} +
+ + +
+ {{/unless}} +
+
+
\ No newline at end of file diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index d39371436c..7b7aea4494 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -336,6 +336,19 @@ +
+
+
+

Dashboard 2.0

+

+ More graphs. Better graphs. Fewer useless graphs. +

+
+
+ +
+
+
{{/if}}