diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index 22536e9ade..032589b12e 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import moment from 'moment-timezone'; import nql from '@tryghost/nql-lang'; +import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_RECEIVED_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEXT_BILLING_DATE_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; import {TrackedArray} from 'tracked-built-ins'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; @@ -11,329 +12,50 @@ function escapeNqlString(value) { return '\'' + value.replace(/'/g, '\\\'') + '\''; } -const MATCH_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is not', name: 'is-not'} -]; - -const CONTAINS_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'contains', name: 'contains'}, - {label: 'does not contain', name: 'does-not-contain'}, - {label: 'starts with', name: 'starts-with'}, - {label: 'ends with', name: 'ends-with'} -]; - -const FEEDBACK_RELATION_OPTIONS = [ - {label: 'More like this', name: 1}, - {label: 'Less like this', name: 0} -]; - -const DATE_RELATION_OPTIONS = [ - {label: 'before', name: 'is-less'}, - {label: 'on or before', name: 'is-or-less'}, - {label: 'after', name: 'is-greater'}, - {label: 'on or after', name: 'is-or-greater'} -]; - -const NUMBER_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is greater than', name: 'is-greater'}, - {label: 'is less than', name: 'is-less'} -]; - -// Ideally we should move all the filter definitions to separate files -const NAME_FILTER = { - label: 'Name', - name: 'name', - group: 'Basic', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS -}; - -const FILTER_PROPERTIES = [ - // Basic - NAME_FILTER, +const FILTER_GROUPS = [ { - label: 'Email', - name: 'email', - group: 'Basic', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS - }, - { - label: 'Label', - name: 'label', - group: 'Basic', - valueType: 'array', - columnLabel: 'Label', - relationOptions: MATCH_RELATION_OPTIONS - }, - { - label: 'Newsletter subscription', - name: 'subscribed', - group: 'Basic', - columnLabel: 'Subscribed', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} + name: 'Basic', + filters: [ + NAME_FILTER, + EMAIL_FILTER, + LABEL_FILTER, + SUBSCRIBED_FILTER, + LAST_SEEN_FILTER, + CREATED_AT_FILTER, + SIGNUP_ATTRIBUTION_FILTER ] }, { - label: 'Last seen', - name: 'last_seen_at', - group: 'Basic', - valueType: 'date', - columnLabel: 'Last seen at', - relationOptions: DATE_RELATION_OPTIONS - }, - { - label: 'Created', - name: 'created_at', - group: 'Basic', - valueType: 'date', - relationOptions: DATE_RELATION_OPTIONS - }, - { - label: 'Signed up on post/page', - name: 'signup', - group: 'Basic', - valueType: 'string', - resource: 'post', - feature: 'memberAttribution', - relationOptions: MATCH_RELATION_OPTIONS, - getColumns: filter => [ - { - label: 'Signed up on', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - } - ] - }, - - // Member subscription - { - label: 'Membership tier', - name: 'tier', - group: 'Subscription', - valueType: 'array', - columnLabel: 'Membership tier', - relationOptions: MATCH_RELATION_OPTIONS - }, - { - label: 'Member status', - name: 'status', - group: 'Subscription', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Paid', name: 'paid'}, - {label: 'Free', name: 'free'}, - {label: 'Complimentary', name: 'comped'} + name: 'Subscription', + filters: [ + TIER_FILTER, + STATUS_FILTER, + PLAN_INTERVAL_FILTER, + SUBSCRIPTION_STATUS_FILTER, + SUBSCRIPTION_START_DATE_FILTER, + NEXT_BILLING_DATE_FILTER, + SUBSCRIPTION_ATTRIBUTION_FILTER ] }, { - label: 'Billing period', - name: 'subscriptions.plan_interval', - group: 'Subscription', - columnLabel: 'Billing period', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Monthly', name: 'month'}, - {label: 'Yearly', name: 'year'} - ] - }, - { - label: 'Stripe subscription status', - name: 'subscriptions.status', - group: 'Subscription', - columnLabel: 'Subscription Status', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Active', name: 'active'}, - {label: 'Trialing', name: 'trialing'}, - {label: 'Canceled', name: 'canceled'}, - {label: 'Unpaid', name: 'unpaid'}, - {label: 'Past Due', name: 'past_due'}, - {label: 'Incomplete', name: 'incomplete'}, - {label: 'Incomplete - Expired', name: 'incomplete_expired'} - ] - }, - { - label: 'Paid start date', - name: 'subscriptions.start_date', - valueType: 'date', - group: 'Subscription', - columnLabel: 'Paid start date', - relationOptions: DATE_RELATION_OPTIONS - }, - { - label: 'Next billing date', - name: 'subscriptions.current_period_end', - valueType: 'date', - group: 'Subscription', - columnLabel: 'Next billing date', - relationOptions: DATE_RELATION_OPTIONS - }, - { - label: 'Subscription started on post/page', - name: 'conversion', - group: 'Subscription', - valueType: 'string', - resource: 'post', - feature: 'memberAttribution', - relationOptions: MATCH_RELATION_OPTIONS, - getColumns: filter => [ - { - label: 'Subscription started on', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - } - ] - }, - - // Emails - { - label: 'Emails sent (all time)', - name: 'email_count', - group: 'Email', - columnLabel: 'Email count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS - }, - { - label: 'Emails opened (all time)', - name: 'email_opened_count', - group: 'Email', - columnLabel: 'Email opened count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS - }, - { - label: 'Open rate (all time)', - name: 'email_open_rate', - group: 'Email', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS - }, - { - label: 'Received email', - name: 'emails.post_id', - group: 'Email', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - getColumns: filter => [ - { - label: 'Received email', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - } - ] - }, - { - label: 'Opened email', - name: 'opened_emails.post_id', - group: 'Email', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - getColumns: filter => [ - { - label: 'Opened email', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - } - ] - }, - { - label: 'Clicked email', - name: 'clicked_links.post_id', - group: 'Email', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - getColumns: filter => [ - { - label: 'Clicked email', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - } - ] - }, - { - label: 'Responded with feedback', - name: 'newsletter_feedback', - group: 'Email', - valueType: 'string', - resource: 'email', - relationOptions: FEEDBACK_RELATION_OPTIONS, - feature: 'audienceFeedback', - buildNqlFilter: (filter) => { - // Added brackets to make sure we can parse as a single AND filter - return `(feedback.post_id:${filter.value}+feedback.score:${filter.relation})`; - }, - parseNqlFilter: (filter) => { - if (!filter.$and) { - return; - } - if (filter.$and.length === 2) { - if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) { - return { - relation: parseInt(filter.$and[1]['feedback.score']), - value: filter.$and[0]['feedback.post_id'] - }; - } - } - }, - getColumns: filter => [ - { - label: 'Email', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - }, - { - label: 'Feedback', - getValue: () => { - return { - class: 'gh-members-list-feedback', - text: filter.relation === 1 ? 'More like this' : 'Less like this', - icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this' - }; - } - } + name: 'Email', + filters: [ + EMAIL_COUNT_FILTER, + EMAIL_OPENED_COUNT_FILTER, + EMAIL_OPEN_RATE_FILTER, + EMAIL_RECEIVED_FILTER, + EMAIL_OPENED_FILTER, + EMAIL_CLICKED_FILTER, + AUDIENCE_FEEDBACK_FILTER ] } ]; +const FILTER_PROPERTIES = FILTER_GROUPS.flatMap(group => group.filters.map((f) => { + f.group = group.name; + return f; +})); + class Filter { @tracked value; @tracked relation; @@ -413,6 +135,7 @@ export default class MembersFilter extends Component { @service session; @service settings; @service store; + @service membersUtils; @tracked filters = new TrackedArray([ new Filter({ @@ -422,10 +145,11 @@ export default class MembersFilter extends Component { get availableFilterProperties() { let availableFilters = FILTER_PROPERTIES; - const hasMultipleTiers = this.store.peekAll('tier').length > 1; + const hasMultipleTiers = this.membersUtils.hasMultipleTiers; // exclude any filters that are behind disabled feature flags availableFilters = availableFilters.filter(prop => !prop.feature || this.feature[prop.feature]); + availableFilters = availableFilters.filter(prop => !prop.setting || this.settings[prop.setting]); // exclude tiers filter if site has only single tier availableFilters = availableFilters diff --git a/ghost/admin/app/components/members/filters/audience-feedback.js b/ghost/admin/app/components/members/filters/audience-feedback.js new file mode 100644 index 0000000000..3551f13563 --- /dev/null +++ b/ghost/admin/app/components/members/filters/audience-feedback.js @@ -0,0 +1,51 @@ +const FEEDBACK_RELATION_OPTIONS = [ + {label: 'More like this', name: 1}, + {label: 'Less like this', name: 0} +]; + +export const AUDIENCE_FEEDBACK_FILTER = { + label: 'Responded with feedback', + name: 'newsletter_feedback', + valueType: 'string', + resource: 'email', + relationOptions: FEEDBACK_RELATION_OPTIONS, + feature: 'audienceFeedback', + buildNqlFilter: (filter) => { + // Added brackets to make sure we can parse as a single AND filter + return `(feedback.post_id:${filter.value}+feedback.score:${filter.relation})`; + }, + parseNqlFilter: (filter) => { + if (!filter.$and) { + return; + } + if (filter.$and.length === 2) { + if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) { + return { + relation: parseInt(filter.$and[1]['feedback.score']), + value: filter.$and[0]['feedback.post_id'] + }; + } + } + }, + getColumns: filter => [ + { + label: 'Email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + }, + { + label: 'Feedback', + getValue: () => { + return { + class: 'gh-members-list-feedback', + text: filter.relation === 1 ? 'More like this' : 'Less like this', + icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this' + }; + } + } + ] +}; diff --git a/ghost/admin/app/components/members/filters/columns/date-column.js b/ghost/admin/app/components/members/filters/columns/date-column.js new file mode 100644 index 0000000000..62bdcb9ef3 --- /dev/null +++ b/ghost/admin/app/components/members/filters/columns/date-column.js @@ -0,0 +1,13 @@ +import moment from 'moment-timezone'; + +export function getDateColumnValue(date, filter) { + if (!date) { + return null; + } + return { + class: '', + text: date ? moment.tz(date, filter.timezone).format('DD MMM YYYY') : '', + subtext: moment(date).from(moment()), + subtextClass: 'gh-members-list-subscribed-moment' + }; +} diff --git a/ghost/admin/app/components/members/filters/created-at.js b/ghost/admin/app/components/members/filters/created-at.js new file mode 100644 index 0000000000..9ded43af78 --- /dev/null +++ b/ghost/admin/app/components/members/filters/created-at.js @@ -0,0 +1,8 @@ +import {DATE_RELATION_OPTIONS} from './relation-options'; + +export const CREATED_AT_FILTER = { + label: 'Created', + name: 'created_at', + valueType: 'date', + relationOptions: DATE_RELATION_OPTIONS +}; diff --git a/ghost/admin/app/components/members/filters/email-clicked.js b/ghost/admin/app/components/members/filters/email-clicked.js new file mode 100644 index 0000000000..d24798b197 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-clicked.js @@ -0,0 +1,16 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const EMAIL_CLICKED_FILTER = { + label: 'Clicked email', + name: 'clicked_links.post_id', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + columnLabel: 'Clicked email', + setting: 'emailTrackClicks', + getColumnValue: (member, filter) => { + return { + text: filter.resource?.title ?? '' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/email-count.js b/ghost/admin/app/components/members/filters/email-count.js new file mode 100644 index 0000000000..ef8396c574 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-count.js @@ -0,0 +1,15 @@ +import {NUMBER_RELATION_OPTIONS} from './relation-options'; +import {formatNumber} from 'ghost-admin/helpers/format-number'; + +export const EMAIL_COUNT_FILTER = { + label: 'Emails sent (all time)', + name: 'email_count', + columnLabel: 'Email count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS, + getColumnValue: (member) => { + return { + text: formatNumber(member.emailCount) + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/email-open-rate.js b/ghost/admin/app/components/members/filters/email-open-rate.js new file mode 100644 index 0000000000..5990199a4d --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-open-rate.js @@ -0,0 +1,9 @@ +import {NUMBER_RELATION_OPTIONS} from './relation-options'; + +export const EMAIL_OPEN_RATE_FILTER = { + label: 'Open rate (all time)', + name: 'email_open_rate', + valueType: 'number', + setting: 'emailTrackOpens', + relationOptions: NUMBER_RELATION_OPTIONS +}; diff --git a/ghost/admin/app/components/members/filters/email-opened-count.js b/ghost/admin/app/components/members/filters/email-opened-count.js new file mode 100644 index 0000000000..ff4d2e3363 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-opened-count.js @@ -0,0 +1,15 @@ +import {NUMBER_RELATION_OPTIONS} from './relation-options'; +import {formatNumber} from 'ghost-admin/helpers/format-number'; + +export const EMAIL_OPENED_COUNT_FILTER = { + label: 'Emails opened (all time)', + name: 'email_opened_count', + columnLabel: 'Email opened count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS, + getColumnValue: (member) => { + return { + text: formatNumber(member.emailOpenedCount) + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/email-opened.js b/ghost/admin/app/components/members/filters/email-opened.js new file mode 100644 index 0000000000..5f284e16a5 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-opened.js @@ -0,0 +1,16 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const EMAIL_OPENED_FILTER = { + label: 'Opened email', + name: 'opened_emails.post_id', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + columnLabel: 'Opened email', + setting: 'emailTrackOpens', + getColumnValue: (member, filter) => { + return { + text: filter.resource?.title ?? '' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/email-received.js b/ghost/admin/app/components/members/filters/email-received.js new file mode 100644 index 0000000000..11605a7ef2 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email-received.js @@ -0,0 +1,15 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const EMAIL_RECEIVED_FILTER = { + label: 'Received email', + name: 'emails.post_id', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + columnLabel: 'Received email', + getColumnValue: (member, filter) => { + return { + text: filter.resource?.title ?? '' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/email.js b/ghost/admin/app/components/members/filters/email.js new file mode 100644 index 0000000000..3645374c34 --- /dev/null +++ b/ghost/admin/app/components/members/filters/email.js @@ -0,0 +1,8 @@ +import {CONTAINS_RELATION_OPTIONS} from './relation-options'; + +export const EMAIL_FILTER = { + label: 'Email', + name: 'email', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS +}; diff --git a/ghost/admin/app/components/members/filters/index.js b/ghost/admin/app/components/members/filters/index.js new file mode 100644 index 0000000000..143ad6b61a --- /dev/null +++ b/ghost/admin/app/components/members/filters/index.js @@ -0,0 +1,22 @@ +export * from './name'; +export * from './email'; +export * from './label'; +export * from './subscribed'; +export * from './last-seen'; +export * from './created-at'; +export * from './signup-attribution'; +export * from './tier'; +export * from './status'; +export * from './plan-interval'; +export * from './subscription-status'; +export * from './subscription-start-date'; +export * from './next-billing-date'; +export * from './subscription-attribution'; +export * from './email-count'; +export * from './email-opened'; +export * from './email-clicked'; +export * from './email-opened-count'; +export * from './email-open-rate'; +export * from './email-clicked'; +export * from './email-received'; +export * from './audience-feedback'; diff --git a/ghost/admin/app/components/members/filters/label.js b/ghost/admin/app/components/members/filters/label.js new file mode 100644 index 0000000000..7538d60df5 --- /dev/null +++ b/ghost/admin/app/components/members/filters/label.js @@ -0,0 +1,15 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const LABEL_FILTER = { + label: 'Label', + name: 'label', + valueType: 'array', + columnLabel: 'Label', + relationOptions: MATCH_RELATION_OPTIONS, + getColumnValue: (member) => { + return { + class: 'gh-members-list-labels', + text: (member.labels ?? []).map(label => label.name).join(', ') + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/last-seen.js b/ghost/admin/app/components/members/filters/last-seen.js new file mode 100644 index 0000000000..22eb950daf --- /dev/null +++ b/ghost/admin/app/components/members/filters/last-seen.js @@ -0,0 +1,13 @@ +import {DATE_RELATION_OPTIONS} from './relation-options'; +import {getDateColumnValue} from './columns/date-column'; + +export const LAST_SEEN_FILTER = { + label: 'Last seen', + name: 'last_seen_at', + valueType: 'date', + columnLabel: 'Last seen at', + relationOptions: DATE_RELATION_OPTIONS, + getColumnValue: (member, filter) => { + return getDateColumnValue(member.lastSeenAtUTC, filter); + } +}; diff --git a/ghost/admin/app/components/members/filters/name.js b/ghost/admin/app/components/members/filters/name.js new file mode 100644 index 0000000000..875d50e5fa --- /dev/null +++ b/ghost/admin/app/components/members/filters/name.js @@ -0,0 +1,8 @@ +import {CONTAINS_RELATION_OPTIONS} from './relation-options'; + +export const NAME_FILTER = { + label: 'Name', + name: 'name', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS +}; diff --git a/ghost/admin/app/components/members/filters/next-billing-date.js b/ghost/admin/app/components/members/filters/next-billing-date.js new file mode 100644 index 0000000000..2d6aad1de9 --- /dev/null +++ b/ghost/admin/app/components/members/filters/next-billing-date.js @@ -0,0 +1,15 @@ +import {DATE_RELATION_OPTIONS} from './relation-options'; +import {getDateColumnValue} from './columns/date-column'; +import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; + +export const NEXT_BILLING_DATE_FILTER = { + label: 'Next billing date', + name: 'subscriptions.current_period_end', + valueType: 'date', + columnLabel: 'Next billing date', + relationOptions: DATE_RELATION_OPTIONS, + getColumnValue: (member, filter) => { + const subscription = mostRecentlyUpdated(member.subscriptions); + return getDateColumnValue(subscription?.current_period_end, filter); + } +}; diff --git a/ghost/admin/app/components/members/filters/plan-interval.js b/ghost/admin/app/components/members/filters/plan-interval.js new file mode 100644 index 0000000000..9ad8df9ec6 --- /dev/null +++ b/ghost/admin/app/components/members/filters/plan-interval.js @@ -0,0 +1,24 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; +import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; +import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; + +export const PLAN_INTERVAL_FILTER = { + label: 'Billing period', + name: 'subscriptions.plan_interval', + columnLabel: 'Billing period', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Monthly', name: 'month'}, + {label: 'Yearly', name: 'year'} + ], + getColumnValue: (member) => { + const subscription = mostRecentlyUpdated(member.subscriptions); + if (!subscription) { + return null; + } + return { + text: capitalizeFirstLetter(subscription.price?.interval) + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/relation-options/contains.js b/ghost/admin/app/components/members/filters/relation-options/contains.js new file mode 100644 index 0000000000..62daf9329b --- /dev/null +++ b/ghost/admin/app/components/members/filters/relation-options/contains.js @@ -0,0 +1,7 @@ +export const CONTAINS_RELATION_OPTIONS = [ + {label: 'is', name: 'is'}, + {label: 'contains', name: 'contains'}, + {label: 'does not contain', name: 'does-not-contain'}, + {label: 'starts with', name: 'starts-with'}, + {label: 'ends with', name: 'ends-with'} +]; diff --git a/ghost/admin/app/components/members/filters/relation-options/date.js b/ghost/admin/app/components/members/filters/relation-options/date.js new file mode 100644 index 0000000000..88725e3686 --- /dev/null +++ b/ghost/admin/app/components/members/filters/relation-options/date.js @@ -0,0 +1,6 @@ +export const DATE_RELATION_OPTIONS = [ + {label: 'before', name: 'is-less'}, + {label: 'on or before', name: 'is-or-less'}, + {label: 'after', name: 'is-greater'}, + {label: 'on or after', name: 'is-or-greater'} +]; diff --git a/ghost/admin/app/components/members/filters/relation-options/index.js b/ghost/admin/app/components/members/filters/relation-options/index.js new file mode 100644 index 0000000000..a4357d6a83 --- /dev/null +++ b/ghost/admin/app/components/members/filters/relation-options/index.js @@ -0,0 +1,4 @@ +export * from './contains'; +export * from './match'; +export * from './date'; +export * from './number'; diff --git a/ghost/admin/app/components/members/filters/relation-options/match.js b/ghost/admin/app/components/members/filters/relation-options/match.js new file mode 100644 index 0000000000..a907a9ac05 --- /dev/null +++ b/ghost/admin/app/components/members/filters/relation-options/match.js @@ -0,0 +1,4 @@ +export const MATCH_RELATION_OPTIONS = [ + {label: 'is', name: 'is'}, + {label: 'is not', name: 'is-not'} +]; diff --git a/ghost/admin/app/components/members/filters/relation-options/number.js b/ghost/admin/app/components/members/filters/relation-options/number.js new file mode 100644 index 0000000000..b4588892ea --- /dev/null +++ b/ghost/admin/app/components/members/filters/relation-options/number.js @@ -0,0 +1,5 @@ +export const NUMBER_RELATION_OPTIONS = [ + {label: 'is', name: 'is'}, + {label: 'is greater than', name: 'is-greater'}, + {label: 'is less than', name: 'is-less'} +]; diff --git a/ghost/admin/app/components/members/filters/signup-attribution.js b/ghost/admin/app/components/members/filters/signup-attribution.js new file mode 100644 index 0000000000..49a396e071 --- /dev/null +++ b/ghost/admin/app/components/members/filters/signup-attribution.js @@ -0,0 +1,16 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const SIGNUP_ATTRIBUTION_FILTER = { + label: 'Signed up on post/page', + name: 'signup', + valueType: 'string', + resource: 'post', + relationOptions: MATCH_RELATION_OPTIONS, + columnLabel: 'Signed up on', + setting: 'membersTrackSources', + getColumnValue: (member, filter) => { + return { + text: filter.resource?.title ?? '' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/status.js b/ghost/admin/app/components/members/filters/status.js new file mode 100644 index 0000000000..e6d31dd697 --- /dev/null +++ b/ghost/admin/app/components/members/filters/status.js @@ -0,0 +1,13 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const STATUS_FILTER = { + label: 'Member status', + name: 'status', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Paid', name: 'paid'}, + {label: 'Free', name: 'free'}, + {label: 'Complimentary', name: 'comped'} + ] +}; diff --git a/ghost/admin/app/components/members/filters/subscribed.js b/ghost/admin/app/components/members/filters/subscribed.js new file mode 100644 index 0000000000..e9be40d4c2 --- /dev/null +++ b/ghost/admin/app/components/members/filters/subscribed.js @@ -0,0 +1,18 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const SUBSCRIBED_FILTER = { + label: 'Newsletter subscription', + name: 'subscribed', + columnLabel: 'Subscribed', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Subscribed', name: 'true'}, + {label: 'Unsubscribed', name: 'false'} + ], + getColumnValue: (member) => { + return { + text: member.subscribed ? 'Yes' : 'No' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/subscription-attribution.js b/ghost/admin/app/components/members/filters/subscription-attribution.js new file mode 100644 index 0000000000..84688dac5d --- /dev/null +++ b/ghost/admin/app/components/members/filters/subscription-attribution.js @@ -0,0 +1,16 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const SUBSCRIPTION_ATTRIBUTION_FILTER = { + label: 'Subscription started on post/page', + name: 'conversion', + valueType: 'string', + resource: 'post', + relationOptions: MATCH_RELATION_OPTIONS, + columnLabel: 'Subscription started on', + setting: 'membersTrackSources', + getColumnValue: (member, filter) => { + return { + text: filter.resource?.title ?? '' + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/subscription-start-date.js b/ghost/admin/app/components/members/filters/subscription-start-date.js new file mode 100644 index 0000000000..42740304e9 --- /dev/null +++ b/ghost/admin/app/components/members/filters/subscription-start-date.js @@ -0,0 +1,15 @@ +import {DATE_RELATION_OPTIONS} from './relation-options'; +import {getDateColumnValue} from './columns/date-column'; +import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; + +export const SUBSCRIPTION_START_DATE_FILTER = { + label: 'Paid start date', + name: 'subscriptions.start_date', + valueType: 'date', + columnLabel: 'Paid start date', + relationOptions: DATE_RELATION_OPTIONS, + getColumnValue: (member, filter) => { + const subscription = mostRecentlyUpdated(member.subscriptions); + return getDateColumnValue(subscription?.start_date, filter); + } +}; diff --git a/ghost/admin/app/components/members/filters/subscription-status.js b/ghost/admin/app/components/members/filters/subscription-status.js new file mode 100644 index 0000000000..750fffdf99 --- /dev/null +++ b/ghost/admin/app/components/members/filters/subscription-status.js @@ -0,0 +1,29 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; +import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; +import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; + +export const SUBSCRIPTION_STATUS_FILTER = { + label: 'Stripe subscription status', + name: 'subscriptions.status', + columnLabel: 'Subscription Status', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Active', name: 'active'}, + {label: 'Trialing', name: 'trialing'}, + {label: 'Canceled', name: 'canceled'}, + {label: 'Unpaid', name: 'unpaid'}, + {label: 'Past Due', name: 'past_due'}, + {label: 'Incomplete', name: 'incomplete'}, + {label: 'Incomplete - Expired', name: 'incomplete_expired'} + ], + getColumnValue: (member) => { + const subscription = mostRecentlyUpdated(member.subscriptions); + if (!subscription) { + return null; + } + return { + text: capitalizeFirstLetter(subscription.status) + }; + } +}; diff --git a/ghost/admin/app/components/members/filters/tier.js b/ghost/admin/app/components/members/filters/tier.js new file mode 100644 index 0000000000..14fba3c506 --- /dev/null +++ b/ghost/admin/app/components/members/filters/tier.js @@ -0,0 +1,15 @@ +import {MATCH_RELATION_OPTIONS} from './relation-options'; + +export const TIER_FILTER = { + label: 'Membership tier', + name: 'tier', + valueType: 'array', + columnLabel: 'Membership tier', + relationOptions: MATCH_RELATION_OPTIONS, + getColumnValue: (member) => { + return { + class: 'gh-members-list-labels', + text: (member.tiers ?? []).map(label => label.name).join(', ') + }; + } +}; diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs index 9fe5c60985..ed268794fb 100644 --- a/ghost/admin/app/components/members/list-item-column.hbs +++ b/ghost/admin/app/components/members/list-item-column.hbs @@ -1,98 +1,15 @@ -{{#if (eq this.columnName 'label')}} - - {{this.labels}} - - -{{else if (eq this.columnName 'tier')}} - - {{this.tiers}} - - -{{else if (eq this.columnName 'last_seen_at')}} - - {{#if (not (is-empty @member.lastSeenAtUTC))}} - {{moment-format (moment-site-tz @member.lastSeenAtUTC) "DD MMM YYYY"}} -
{{moment-from-now @member.lastSeenAtUTC}}
- {{else}} - - - {{/if}} -
- -{{else if (eq this.columnName 'email_count')}} - - {{#if (not (is-empty @member.emailCount))}} - {{@member.emailCount}} - {{else}} - - - {{/if}} - - -{{else if (eq this.columnName 'email_opened_count')}} - - {{#if (not (is-empty @member.emailOpenedCount))}} - {{@member.emailOpenedCount}} - {{else}} - - - {{/if}} - - -{{else if (eq this.columnName 'subscribed')}} - - {{#if (not (is-empty @member.subscribed))}} - {{if @member.subscribed "Yes" "No"}} - {{else}} - - - {{/if}} - - -{{else if (eq this.columnName 'subscriptions.status')}} - - {{#if (not (is-empty this.mostRecentSubscription.status))}} - {{capitalize this.mostRecentSubscription.status}} - {{else}} - - - {{/if}} - - -{{else if (eq this.columnName 'subscriptions.plan_interval')}} - - {{#if (not (is-empty this.mostRecentSubscription.price.interval))}} - {{capitalize this.mostRecentSubscription.price.interval}} - {{else}} - - - {{/if}} - - -{{else if (eq this.columnName 'subscriptions.start_date')}} - - {{#if (not (is-empty this.mostRecentSubscription.start_date))}} - {{moment-format (moment-site-tz this.mostRecentSubscription.start_date) "DD MMM YYYY"}} -
{{moment-from-now this.mostRecentSubscription.start_date}}
- {{else}} - - - {{/if}} -
- -{{else if (eq this.columnName 'subscriptions.current_period_end')}} - - {{#if (not (is-empty this.mostRecentSubscription.current_period_end))}} - {{moment-format (moment-site-tz this.mostRecentSubscription.current_period_end) "DD MMM YYYY"}} -
{{moment-from-now this.mostRecentSubscription.current_period_end}}
- {{else}} - - - {{/if}} -
-{{else}} - - {{#if this.columnValue}} -
- {{#if this.columnValue.icon}} - {{svg-jar this.columnValue.icon}} - {{/if}} - {{this.columnValue.text}} -
- {{else}} - - - {{/if}} -
-{{/if}} + + {{#if this.columnValue}} +
+ {{#if this.columnValue.icon}} + {{svg-jar this.columnValue.icon}} + {{/if}} + {{this.columnValue.text}} + {{#if this.columnValue.subtext}} +
{{this.columnValue.subtext}}
+ {{/if}} +
+ {{else}} + - + {{/if}} +
diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js index 3537ca55f2..14b1c5fbdd 100644 --- a/ghost/admin/app/components/members/list-item-column.js +++ b/ghost/admin/app/components/members/list-item-column.js @@ -1,26 +1,10 @@ import Component from '@glimmer/component'; -import {get} from '@ember/object'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; export default class MembersListItemColumn extends Component { constructor(...args) { super(...args); } - get labels() { - const labelData = get(this.args.member, 'labels') || []; - return labelData.map(label => label.name).join(', '); - } - - get tiers() { - const tierData = get(this.args.member, 'tiers') || []; - return tierData.map(tier => tier.name).join(', '); - } - - get mostRecentSubscription() { - return mostRecentlyUpdated(get(this.args.member, 'subscriptions')); - } - get columnName() { return this.args.filterColumn.name; } diff --git a/ghost/admin/app/components/members/list-item.hbs b/ghost/admin/app/components/members/list-item.hbs index 53f8b02c09..e5aa48071c 100644 --- a/ghost/admin/app/components/members/list-item.hbs +++ b/ghost/admin/app/components/members/list-item.hbs @@ -29,15 +29,13 @@ {{/if}} {{#if @newsletterEnabled}} - {{#if (feature "emailAnalytics")}} - - {{#if (not (is-empty @member.emailOpenRate))}} - {{@member.emailOpenRate}}% - {{else}} - N/A - {{/if}} - - {{/if}} + + {{#if (not (is-empty @member.emailOpenRate))}} + {{@member.emailOpenRate}}% + {{else}} + N/A + {{/if}} + {{/if}} diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index 4576fcbbab..d928ff1fc8 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -176,14 +176,19 @@ export default class MembersController extends Controller { return this.availableFilters.flatMap((filter) => { if (filter.properties?.getColumns) { return filter.properties?.getColumns(filter).map((c) => { - return {...c, name: filter.type}; + return { + label: filter.properties.columnLabel, // default value if not provided + ...c, + name: filter.type + }; }); } if (filter.properties?.columnLabel) { return [ { name: filter.type, - label: filter.properties.columnLabel + label: filter.properties.columnLabel, + getValue: filter.properties.getColumnValue ? (member => filter.properties.getColumnValue(member, filter)) : null } ]; } diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 0c0b990005..73c1704d80 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -155,6 +155,7 @@ p.gh-members-list-email { display: inline-block; max-width: 300px; min-width: 220px; + white-space: wrap; } .gh-members-list-feedback{ diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index c6db4abf7c..60b4424df8 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -129,7 +129,7 @@ {{this.listHeader}} Status - {{#if (not-eq this.settings.editorDefaultEmailRecipients "disabled")}} + {{#if (and (not-eq this.settings.editorDefaultEmailRecipients "disabled") this.settings.emailTrackOpens)}} Open rate {{/if}} Location @@ -142,12 +142,12 @@ {{#if member.is_loading}} {{else}}