diff --git a/apps/portal/src/components/TriggerButton.js b/apps/portal/src/components/TriggerButton.js index 5cc0ef6519..fcb2cd01cf 100644 --- a/apps/portal/src/components/TriggerButton.js +++ b/apps/portal/src/components/TriggerButton.js @@ -9,7 +9,7 @@ import {ReactComponent as ButtonIcon3} from '../images/icons/button-icon-3.svg'; import {ReactComponent as ButtonIcon4} from '../images/icons/button-icon-4.svg'; import {ReactComponent as ButtonIcon5} from '../images/icons/button-icon-5.svg'; import TriggerButtonStyle from './TriggerButton.styles'; -import {isInviteOnlySite} from '../utils/helpers'; +import {isInviteOnlySite, isSigninAllowed} from '../utils/helpers'; import {hasMode} from '../utils/check-mode'; const ICON_MAPPING = { @@ -164,12 +164,21 @@ class TriggerButtonContent extends React.Component { onToggle() { const {showPopup, member, site} = this.context; + if (showPopup) { this.context.onAction('closePopup'); - } else { - const loggedOutPage = isInviteOnlySite({site}) ? 'signin' : 'signup'; - const page = member ? 'accountHome' : loggedOutPage; + return; + } + + if (member) { + this.context.onAction('openPopup', {page: 'accountHome'}); + return; + } + + if (isSigninAllowed({site})) { + const page = isInviteOnlySite({site}) ? 'signin' : 'signup'; this.context.onAction('openPopup', {page}); + return; } } @@ -240,10 +249,11 @@ export default class TriggerButton extends React.Component { } render() { - const {portal_button: portalButton} = this.context.site; + const site = this.context.site; + const {portal_button: portalButton} = site; const {showPopup} = this.context; - if (!portalButton || hasMode(['offerPreview'])) { + if (!portalButton || !isSigninAllowed({site}) || hasMode(['offerPreview'])) { return null; } diff --git a/apps/portal/src/components/pages/AccountHomePage/AccountHomePage.js b/apps/portal/src/components/pages/AccountHomePage/AccountHomePage.js index 7cd092b313..cd55eb6369 100644 --- a/apps/portal/src/components/pages/AccountHomePage/AccountHomePage.js +++ b/apps/portal/src/components/pages/AccountHomePage/AccountHomePage.js @@ -4,12 +4,18 @@ import {getSupportAddress} from '../../../utils/helpers'; import AccountFooter from './components/AccountFooter'; import AccountMain from './components/AccountMain'; +import {isSigninAllowed} from '../../../utils/helpers'; export default class AccountHomePage extends React.Component { static contextType = AppContext; componentDidMount() { - const {member} = this.context; + const {member, site} = this.context; + + if (!isSigninAllowed({site})) { + this.context.onAction('signout'); + } + if (!member) { this.context.onAction('switchPage', { page: 'signin', @@ -31,6 +37,9 @@ export default class AccountHomePage extends React.Component { if (!member) { return null; } + if (!isSigninAllowed({site})) { + return null; + } return (
diff --git a/apps/portal/src/components/pages/AccountHomePage/components/SubscribeButton.js b/apps/portal/src/components/pages/AccountHomePage/components/SubscribeButton.js index fd0ab0d63c..e5d4e4ffc2 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/SubscribeButton.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/SubscribeButton.js @@ -1,13 +1,12 @@ import AppContext from '../../../../AppContext'; import ActionButton from '../../../common/ActionButton'; -import {hasOnlyFreePlan} from '../../../../utils/helpers'; +import {isSignupAllowed} from '../../../../utils/helpers'; import {useContext} from 'react'; const SubscribeButton = () => { const {site, action, brandColor, onAction, t} = useContext(AppContext); - const {is_stripe_configured: isStripeConfigured} = site; - if (!isStripeConfigured || hasOnlyFreePlan({site})) { + if (!isSignupAllowed({site})) { return null; } const isRunning = ['checkoutPlan:running'].includes(action); diff --git a/apps/portal/src/components/pages/SigninPage.js b/apps/portal/src/components/pages/SigninPage.js index 45922a0357..ad60cf44d8 100644 --- a/apps/portal/src/components/pages/SigninPage.js +++ b/apps/portal/src/components/pages/SigninPage.js @@ -5,6 +5,8 @@ import CloseButton from '../common/CloseButton'; import AppContext from '../../AppContext'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; +import {isSigninAllowed} from '../../utils/helpers'; +import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; export default class SigninPage extends React.Component { static contextType = AppContext; @@ -116,6 +118,23 @@ export default class SigninPage extends React.Component { } renderForm() { + const {site, t} = this.context; + + if (!isSigninAllowed({site})) { + return ( +
+
+

+ {t('Memberships unavailable, contact the owner for access.')} +

+
+
+ ); + } + return (
@@ -125,32 +144,52 @@ export default class SigninPage extends React.Component { onKeyDown={(e, field) => this.onKeyDown(e, field)} />
+
+ {this.renderSubmitButton()} + {this.renderSignupMessage()} +
); } - renderSiteLogo() { - const siteLogo = this.context.site.icon; + renderSiteIcon() { + const iconStyle = {}; + const {site} = this.context; + const siteIcon = site.icon; - const logoStyle = {}; - - if (siteLogo) { - logoStyle.backgroundImage = `url(${siteLogo})`; + if (siteIcon) { + iconStyle.backgroundImage = `url(${siteIcon})`; return ( - {this.context.site.title} + {this.context.site.title} + ); + } else if (!isSigninAllowed({site})) { + return ( + ); } return null; } - renderFormHeader() { - // const siteTitle = this.context.site.title || 'Site Title'; - const {t} = this.context; + renderSiteTitle() { + const {site, t} = this.context; + const siteTitle = site.title; + if (!isSigninAllowed({site})) { + return ( +

{siteTitle}

+ ); + } else { + return ( +

{t('Sign in')}

+ ); + } + } + + renderFormHeader() { return (
- {this.renderSiteLogo()} -

{t('Sign in')}

+ {this.renderSiteIcon()} + {this.renderSiteTitle()}
); } @@ -158,19 +197,12 @@ export default class SigninPage extends React.Component { render() { return ( <> - {/*
- -
*/}
{this.renderFormHeader()} {this.renderForm()}
-
- {this.renderSubmitButton()} - {this.renderSignupMessage()} -
); diff --git a/apps/portal/src/components/pages/SigninPage.test.js b/apps/portal/src/components/pages/SigninPage.test.js index 1e4d4def09..72fa3aba8f 100644 --- a/apps/portal/src/components/pages/SigninPage.test.js +++ b/apps/portal/src/components/pages/SigninPage.test.js @@ -1,18 +1,30 @@ -import {render, fireEvent} from '../../utils/test-utils'; +import {render, fireEvent, getByTestId} from '../../utils/test-utils'; import SigninPage from './SigninPage'; +import {getSiteData} from '../../utils/fixtures-generator'; -const setup = () => { +const setup = (overrides) => { const {mockOnActionFn, ...utils} = render( , { overrideContext: { - member: null + member: null, + ...overrides } } ); - const emailInput = utils.getByLabelText(/email/i); - const submitButton = utils.queryByRole('button', {name: 'Continue'}); - const signupButton = utils.queryByRole('button', {name: 'Sign up'}); + + let emailInput; + let submitButton; + let signupButton; + + try { + emailInput = utils.getByLabelText(/email/i); + submitButton = utils.queryByRole('button', {name: 'Continue'}); + signupButton = utils.queryByRole('button', {name: 'Sign up'}); + } catch (err) { + // ignore + } + return { emailInput, submitButton, @@ -47,4 +59,17 @@ describe('SigninPage', () => { fireEvent.click(signupButton); expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signup'}); }); + + describe('when members are disabled', () => { + test('renders an informative message', () => { + setup({ + site: getSiteData({ + membersSignupAccess: 'none' + }) + }); + + const message = getByTestId(document.body, 'members-disabled-notification-text'); + expect(message).toBeInTheDocument(); + }); + }); }); diff --git a/apps/portal/src/components/pages/SignupPage.js b/apps/portal/src/components/pages/SignupPage.js index 3c762b441a..4eebf8440d 100644 --- a/apps/portal/src/components/pages/SignupPage.js +++ b/apps/portal/src/components/pages/SignupPage.js @@ -7,7 +7,7 @@ import NewsletterSelectionPage from './NewsletterSelectionPage'; import ProductsSection from '../common/ProductsSection'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; -import {getSiteProducts, getSitePrices, hasOnlyFreePlan, isInviteOnlySite, freeHasBenefitsOrDescription, hasOnlyFreeProduct, getFreeProductBenefits, getFreeTierDescription, hasFreeProductPrice, hasMultipleNewsletters, hasFreeTrialTier} from '../../utils/helpers'; +import {getSiteProducts, getSitePrices, hasOnlyFreePlan, isInviteOnlySite, freeHasBenefitsOrDescription, hasOnlyFreeProduct, getFreeProductBenefits, getFreeTierDescription, hasFreeProductPrice, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed} from '../../utils/helpers'; import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; export const SignupPageStyles = ` @@ -171,7 +171,7 @@ footer.gh-portal-signup-footer.invite-only .gh-portal-signup-message { margin-top: 0; } -.gh-portal-invite-only-notification { +.gh-portal-invite-only-notification, .gh-portal-members-disabled-notification { margin: 8px 32px 24px; padding: 0; text-align: center; @@ -670,6 +670,21 @@ class SignupPage extends React.Component { ); } + if (!isSignupAllowed({site})) { + return ( +
+
+

+ {t('Memberships unavailable, contact the owner for access.')} +

+
+
+ ); + } + const freeBenefits = getFreeProductBenefits({site}); const freeDescription = getFreeTierDescription({site}); const showOnlyFree = pageQuery === 'free' && hasFreeProductPrice({site}); @@ -716,22 +731,22 @@ class SignupPage extends React.Component { ); } - renderSiteLogo() { + renderSiteIcon() { const {site, pageQuery} = this.context; + const siteIcon = site.icon; - const siteLogo = site.icon; - - const logoStyle = {}; - - if (siteLogo) { - logoStyle.backgroundImage = `url(${siteLogo})`; + if (siteIcon) { return ( - {site.title} + {site.title} ); } else if (isInviteOnlySite({site, pageQuery})) { return ( ); + } else if (!isSignupAllowed({site})) { + return ( + + ); } return null; } @@ -741,7 +756,7 @@ class SignupPage extends React.Component { const siteTitle = site.title || ''; return (
- {this.renderSiteLogo()} + {this.renderSiteIcon()}

{siteTitle}

); @@ -794,10 +809,6 @@ class SignupPage extends React.Component { {this.renderFormHeader()} {this.renderForm()}
- {/* */} ); } diff --git a/apps/portal/src/components/pages/SignupPage.test.js b/apps/portal/src/components/pages/SignupPage.test.js index 7087e0758c..9a70e0665d 100644 --- a/apps/portal/src/components/pages/SignupPage.test.js +++ b/apps/portal/src/components/pages/SignupPage.test.js @@ -1,6 +1,6 @@ import SignupPage from './SignupPage'; import {getFreeProduct, getProductData, getSiteData} from '../../utils/fixtures-generator'; -import {render, fireEvent} from '../../utils/test-utils'; +import {render, fireEvent, getByTestId} from '../../utils/test-utils'; const setup = (overrides) => { const {mockOnActionFn, ...utils} = render( @@ -12,12 +12,25 @@ const setup = (overrides) => { } } ); - const emailInput = utils.getByLabelText(/email/i); - const nameInput = utils.getByLabelText(/name/i); - const submitButton = utils.queryByRole('button', {name: 'Continue'}); - const chooseButton = utils.queryAllByRole('button', {name: 'Choose'}); - const signinButton = utils.queryByRole('button', {name: 'Sign in'}); - const freeTrialMessage = utils.queryByText(/After a free trial ends/i); + + let emailInput; + let nameInput; + let submitButton; + let chooseButton; + let signinButton; + let freeTrialMessage; + + try { + emailInput = utils.getByLabelText(/email/i); + nameInput = utils.getByLabelText(/name/i); + submitButton = utils.queryByRole('button', {name: 'Continue'}); + chooseButton = utils.queryAllByRole('button', {name: 'Choose'}); + signinButton = utils.queryByRole('button', {name: 'Sign in'}); + freeTrialMessage = utils.queryByText(/After a free trial ends/i); + } catch (err) { + // ignore + } + return { nameInput, emailInput, @@ -89,4 +102,17 @@ describe('SignupPage', () => { expect(freeTrialMessage).not.toBeInTheDocument(); }); + + describe('when members are disabled', () => { + test('renders an informative message', () => { + setup({ + site: getSiteData({ + membersSignupAccess: 'none' + }) + }); + + const message = getByTestId(document.body, 'members-disabled-notification-text'); + expect(message).toBeInTheDocument(); + }); + }); }); diff --git a/apps/portal/src/utils/helpers.js b/apps/portal/src/utils/helpers.js index f523b82dba..c5ac20b524 100644 --- a/apps/portal/src/utils/helpers.js +++ b/apps/portal/src/utils/helpers.js @@ -238,6 +238,14 @@ export function isInviteOnlySite({site = {}, pageQuery = ''}) { return prices.length === 0 || (site && site.members_signup_access === 'invite'); } +export function isSigninAllowed({site}) { + return site?.members_signup_access === 'all' || site?.members_signup_access === 'invite'; +} + +export function isSignupAllowed({site}) { + return site?.members_signup_access === 'all' && (site?.is_stripe_configured || hasOnlyFreePlan({site})); +} + export function hasMultipleProducts({site}) { const products = getAvailableProducts({site}); diff --git a/apps/portal/src/utils/helpers.test.js b/apps/portal/src/utils/helpers.test.js index b0aab5edcd..2fa9dd39ef 100644 --- a/apps/portal/src/utils/helpers.test.js +++ b/apps/portal/src/utils/helpers.test.js @@ -1,4 +1,4 @@ -import {getAllProductsForSite, getAvailableProducts, getCurrencySymbol, getFreeProduct, getMemberName, getMemberSubscription, getPriceFromSubscription, getPriceIdFromPageQuery, getSupportAddress, getUrlHistory, hasMultipleProducts, isActiveOffer, isInviteOnlySite, isPaidMember, isSameCurrency, transformApiTiersData} from './helpers'; +import {getAllProductsForSite, getAvailableProducts, getCurrencySymbol, getFreeProduct, getMemberName, getMemberSubscription, getPriceFromSubscription, getPriceIdFromPageQuery, getSupportAddress, getUrlHistory, hasMultipleProducts, isActiveOffer, isInviteOnlySite, isPaidMember, isSameCurrency, transformApiTiersData, isSigninAllowed, isSignupAllowed} from './helpers'; import * as Fixtures from './fixtures-generator'; import {site as FixturesSite, member as FixtureMember, offer as FixtureOffer, transformTierFixture as TransformFixtureTiers} from '../utils/test-fixtures'; import {isComplimentaryMember} from '../utils/helpers'; @@ -137,8 +137,12 @@ describe('Helpers - ', () => { }); describe('isInviteOnlySite - ', () => { - test('returns true for invite only site', () => { - const value = isInviteOnlySite({site: FixturesSite.singleTier.inviteOnly}); + test('returns true for a site without plans', () => { + const value = isInviteOnlySite({site: FixturesSite.singleTier.withoutPlans}); + expect(value).toBe(true); + }); + test('returns true for a site with invite-only members', () => { + const value = isInviteOnlySite({site: FixturesSite.singleTier.membersInviteOnly}); expect(value).toBe(true); }); test('returns false for non invite only site', () => { @@ -147,6 +151,45 @@ describe('Helpers - ', () => { }); }); + describe('isSigninAllowed - ', () => { + test('returns true for a site with members enabled', () => { + const value = isSigninAllowed({site: FixturesSite.singleTier.basic}); + expect(value).toBe(true); + }); + + test('returns true for a site with invite-only members', () => { + const value = isSigninAllowed({site: FixturesSite.singleTier.membersInviteOnly}); + expect(value).toBe(true); + }); + + test('returns false for a site with members disabled', () => { + const value = isSigninAllowed({site: FixturesSite.singleTier.membersDisabled}); + expect(value).toBe(false); + }); + }); + + describe('isSignupAllowed - ', () => { + test('returns true for a site with members enabled, and with Stripe configured', () => { + const value = isSignupAllowed({site: FixturesSite.singleTier.basic}); + expect(value).toBe(true); + }); + + test('returns true for a site with members enabled, without Stripe configured, but with only free tiers', () => { + const value = isSignupAllowed({site: FixturesSite.singleTier.onlyFreePlanWithoutStripe}); + expect(value).toBe(true); + }); + + test('returns false for a site with invite-only members', () => { + const value = isSignupAllowed({site: FixturesSite.singleTier.membersInviteOnly}); + expect(value).toBe(false); + }); + + test('returns false for a site with members disabled', () => { + const value = isSignupAllowed({site: FixturesSite.singleTier.membersDisabled}); + expect(value).toBe(false); + }); + }); + describe('hasMultipleProducts - ', () => { test('returns true for multiple tier site', () => { const value = hasMultipleProducts({site: FixturesSite.multipleTiers.basic}); diff --git a/apps/portal/src/utils/test-fixtures.js b/apps/portal/src/utils/test-fixtures.js index 42d2268848..cca3b62a77 100644 --- a/apps/portal/src/utils/test-fixtures.js +++ b/apps/portal/src/utils/test-fixtures.js @@ -136,7 +136,7 @@ const baseMultiTierSite = getSiteData({ export const site = { singleTier: { basic: baseSingleTierSite, - inviteOnly: { + withoutPlans: { ...baseSingleTierSite, portal_plans: [] }, @@ -151,6 +151,23 @@ export const site = { withoutName: { ...baseSingleTierSite, portal_name: false + }, + withoutStripe: { + ...baseSingleTierSite, + is_stripe_configured: false + }, + onlyFreePlanWithoutStripe: { + ...baseSingleTierSite, + portal_plans: ['free'], + is_stripe_configured: false + }, + membersInviteOnly: { + ...baseSingleTierSite, + members_signup_access: 'invite' + }, + membersDisabled: { + ...baseSingleTierSite, + members_signup_access: 'none' } }, multipleTiers: { diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js index 0dd23be518..cac065e088 100644 --- a/ghost/core/core/frontend/helpers/ghost_head.js +++ b/ghost/core/core/frontend/helpers/ghost_head.js @@ -45,9 +45,11 @@ function finaliseStructuredData(meta) { } function getMembersHelper(data, frontendKey) { - if (!settingsCache.get('members_enabled')) { + // Do not load Portal if both Memberships and Tips & Donations are disabled + if (!settingsCache.get('members_enabled') && !settingsCache.get('donations_enabled')) { return ''; } + const {scriptUrl} = getFrontendAppConfig('portal'); const colorString = (_.has(data, 'site._preview') && data.site.accent_color) ? data.site.accent_color : '';