Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/portal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
"version": "2.68.17",
"version": "2.68.18",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import AppContext from '../../../../app-context';
import ActionButton from '../../../common/action-button';
import {getSubscriptionExpiry, isGiftMember} from '../../../../utils/helpers';
import {getSubscriptionExpiry, isArchivedTier, isGiftMember} from '../../../../utils/helpers';
import {useContext} from 'react';

const ContinueGiftSubscriptionBanner = () => {
const {member, doAction, action, brandColor} = useContext(AppContext);
const {member, site, doAction, action, brandColor} = useContext(AppContext);

if (!isGiftMember({member})) {
if (!isGiftMember({member}) || isArchivedTier({member, site})) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import AppContext from '../../../../app-context';
import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, isGiftMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers';
import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isArchivedTier, isComplimentaryMember, isGiftMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers';
import {getDateString} from '../../../../utils/date-time';
import {ReactComponent as GiftIcon} from '../../../../images/icons/gift.svg';
import {ReactComponent as LoaderIcon} from '../../../../images/icons/loader.svg';
Expand Down Expand Up @@ -97,10 +97,21 @@ const PaidAccountActions = () => {
};

const PlanUpdateButton = ({isPaid}) => {
if (hasOnlyFreePlan({site}) && !isPaid) {
const hasGiftSubscription = isGiftMember({member});
const canContinueGiftSubscription = hasGiftSubscription && !isArchivedTier({member, site});

// If no paid tiers are available, hide the plan update button for:
// - Free members, as they have no paid plans to upgrade to
// - Gift members on archived tiers, as they have no paid plans to upgrade to
//
// In constrast, still render the button for:
// - Paid members so that they can adjust the cadence on their existing sub
// - Comped members so that they can contact publishers to make changes to their complimentary access
if (hasOnlyFreePlan({site}) && (!isPaid || (hasGiftSubscription && !canContinueGiftSubscription))) {
return null;
}
if (isGiftMember({member})) {

if (canContinueGiftSubscription) {
return (
<button
className='gh-portal-btn gh-portal-btn-list' onClick={() => doAction('continueGiftSubscription')}
Expand Down
12 changes: 12 additions & 0 deletions apps/portal/src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ export function getSubscriptionExpiry({member}) {
return '';
}

export function isArchivedTier({member, site}) {
const subscription = getMemberSubscription({member});
const tierId = subscription?.tier?.id;

if (!tierId) {
return false;
}

// Archived tiers are filtered out of site.products
return !getProductFromId({site, productId: tierId});
}

export function getUpgradeProducts({site, member}) {
const activePrice = getMemberActivePrice({member});
const activePriceCurrency = activePrice?.currency;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {render} from '../../../../utils/test-utils';
import PaidAccountActions from '../../../../../src/components/pages/AccountHomePage/components/paid-account-actions';
import {getDiscountData, getMemberData, getNextPaymentData, getSubscriptionData, getSiteData, getProductsData} from '../../../../../src/utils/fixtures-generator';
import {getDiscountData, getMemberData, getNextPaymentData, getSubscriptionData, getSiteData, getProductsData, getProductData} from '../../../../../src/utils/fixtures-generator';

const setup = (overrides) => {
const {mockDoActionFn, ...utils} = render(
Expand Down Expand Up @@ -507,6 +507,173 @@ describe('PaidAccountActions', () => {
});
});

describe('PlanUpdateButton', () => {
const buildPaidSite = () => {
const products = getProductsData({numOfProducts: 1});
return {
site: getSiteData({products, portalProducts: products.map(p => p.id)}),
products
};
};

const buildFreeOnlySite = () => {
const products = getProductsData({numOfProducts: 1});
return getSiteData({
products,
portalProducts: products.map(p => p.id),
portalPlans: ['free']
});
};

const buildGiftMember = ({tierId}) => getMemberData({
paid: true,
status: 'gift',
subscriptions: [
getSubscriptionData({
status: 'active',
amount: 0,
currency: 'USD',
interval: 'month',
tier: {id: tierId, expiry_at: new Date('2099-01-01T12:00:00.000Z')}
})
]
});

test('renders "Continue" for a gift member whose tier is still active', () => {
const {site, products} = buildPaidSite();
const member = buildGiftMember({tierId: products[0].id});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="continue-gift-subscription"]')).toBeInTheDocument();
expect(container.querySelector('[data-test-button="change-plan"]')).not.toBeInTheDocument();
});

test('renders "Change" for a gift member when the tier has been archived', () => {
// Archived tier = tier id absent from site.products
const {site} = buildPaidSite();
const archivedTier = getProductData({name: 'Archived'});
const member = buildGiftMember({tierId: archivedTier.id});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).toBeInTheDocument();
expect(container.querySelector('[data-test-button="continue-gift-subscription"]')).not.toBeInTheDocument();
});

test('renders nothing for a gift on an archived tier when no paid plans are available', () => {
const site = buildFreeOnlySite();
const archivedTier = getProductData({name: 'Archived'});
const member = buildGiftMember({tierId: archivedTier.id});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="continue-gift-subscription"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-test-button="change-plan"]')).not.toBeInTheDocument();
});

test('renders "Change" for a regular paid member when paid plans are available', () => {
const {site} = buildPaidSite();
const member = getMemberData({
paid: true,
subscriptions: [
getSubscriptionData({
status: 'active',
amount: 500,
currency: 'USD',
interval: 'month'
})
]
});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).toBeInTheDocument();
});

test('still renders "Change" for a regular paid member on a free-only site', () => {
// Paid members keep the Change button even when no paid plans are
// exposed in Portal — the upgrade page is the only place they can
// see the contact-publisher message.
const site = buildFreeOnlySite();
const member = getMemberData({
paid: true,
subscriptions: [
getSubscriptionData({
status: 'active',
amount: 500,
currency: 'USD',
interval: 'month'
})
]
});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).toBeInTheDocument();
});

test('renders "Change" for a comped member when paid plans are available', () => {
const {site} = buildPaidSite();
const member = getMemberData({
paid: true,
status: 'comped',
subscriptions: [
getSubscriptionData({
status: 'active',
amount: 0,
currency: 'USD',
interval: 'month'
})
]
});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).toBeInTheDocument();
expect(container.querySelector('[data-test-button="continue-gift-subscription"]')).not.toBeInTheDocument();
});

test('still renders "Change" for a comped member on a free-only site', () => {
// Comped members keep the Change button so they can reach the
// upgrade page and see the contact-publisher message.
const site = buildFreeOnlySite();
const member = getMemberData({
paid: true,
status: 'comped',
subscriptions: [
getSubscriptionData({
status: 'active',
amount: 0,
currency: 'USD',
interval: 'month'
})
]
});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).toBeInTheDocument();
});

test('renders nothing for a free member', () => {
// Free members have no subscription and aren't complimentary, so
// PaidAccountActions short-circuits before PlanUpdateButton is reached.
const {site} = buildPaidSite();
const member = getMemberData({
paid: false,
status: 'free',
subscriptions: []
});

const {container} = setup({site, member});

expect(container.querySelector('[data-test-button="change-plan"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-test-button="continue-gift-subscription"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-test-button="manage-billing"]')).not.toBeInTheDocument();
});
});

describe('Canceled badge', () => {
test('displays CANCELED badge when cancel_at_period_end is true', () => {
const products = getProductsData({numOfProducts: 1});
Expand Down
32 changes: 32 additions & 0 deletions apps/portal/test/utils/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isActiveOffer,
isRetentionOffer,
isInviteOnly,
isArchivedTier,
isGiftMember,
isPaidMember,
isPaidMembersOnly,
Expand Down Expand Up @@ -760,6 +761,37 @@ describe('Helpers - ', () => {
});
});

describe('isArchivedTier', () => {
const site = FixturesSite.singleTier.basic;
const activeTierId = site.products.find(p => p.type === 'paid').id;

const buildMemberWithTierId = tierId => ({
paid: true,
status: 'gift',
subscriptions: [
{
status: 'active',
price: {amount: 0},
tier: tierId === undefined ? {} : {id: tierId}
}
]
});

test('returns false when the member has no subscription', () => {
expect(isArchivedTier({member: FixtureMember.free, site})).toBe(false);
});

test('returns false when the subscription tier id is in site.products', () => {
const member = buildMemberWithTierId(activeTierId);
expect(isArchivedTier({member, site})).toBe(false);
});

test('returns true when the subscription tier id is not in site.products', () => {
const member = buildMemberWithTierId('archived_tier_id');
expect(isArchivedTier({member, site})).toBe(true);
});
});

describe('isInThePast', () => {
it('returns a boolean indicating if the provided date is in the past', () => {
const pastDate = new Date();
Expand Down
2 changes: 1 addition & 1 deletion ghost/admin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
"version": "6.34.0-rc.0",
"version": "6.35.0-rc.0",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
Expand Down
15 changes: 15 additions & 0 deletions ghost/core/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ module.exports = {
'ghost/filenames/match-regex': ['error', '^[a-z0-9_.-]+$', false]
}
},
{
// Browser-only scripts served by the admin iframe bridge. They run in
// the reader's browser, so `window`/`fetch` etc. are real globals,
// Ghost's Node-only `@tryghost/errors` classes are unavailable, and
// `console` is the only diagnostic channel available for debugging
// bridge configuration/integration issues in devtools.
files: 'core/frontend/src/admin-auth/**/*.js',
env: {
browser: true
},
rules: {
'ghost/ghost-custom/no-native-error': 'off',
'no-console': 'off'
}
},
/**
* @TODO: enable these soon
*/
Expand Down
12 changes: 11 additions & 1 deletion ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,28 @@ const debug = require('@tryghost/debug')('boot');
* Helper class to create consistent log messages
*/
class BootLogger {
/**
* @param {{info: (message: string) => unknown}} logging
* @param {{metric: (name: string, time: number) => unknown}} metrics
* @param {number} startTime
*/
constructor(logging, metrics, startTime) {
this.logging = logging;
this.metrics = metrics;
this.startTime = startTime;
}
/**
* @param {string} message
* @returns {void}
*/
log(message) {
let {logging, startTime} = this;
logging.info(`Ghost ${message} in ${(Date.now() - startTime) / 1000}s`);
}
/**
* @param {string} name
* @param {number} [initialTime]
* @returns {void}
*/
metric(name, initialTime) {
let {metrics, startTime} = this;
Expand Down Expand Up @@ -161,7 +171,7 @@ async function initCore({ghostServer, config, frontend}) {
/**
* These are services required by Ghost's frontend.
* @param {object} options
* @param {object} options.bootLogger
* @param {BootLogger} options.bootLogger

*/
async function initServicesForFrontend({bootLogger}) {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading