2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

Added logic to load Portal when Tips & Donations are enabled (#17634)

closes https://github.com/TryGhost/Product/issues/3661

- until now, Portal was not loaded if members were disabled. With the
introduction of Tips & Donations, signed-off readers can also make
payments, using the Portal link /#/portal/support.
- now, Portal is loaded when Tips & Donations are enabled, even if
Memberships are disabled
- depending on the member signup access, the "sign in" / "subscribe"
Portal buttons are hidden (both hidden if none, signup hidden if
invite-only)
- for any other signup / signin Portal links (e.g., added by the theme,
or added via a Post/Page), a new popup informs the reader as such when
Memberships are disabled: "Memberships unavailable, contact the site owner for access".
This commit is contained in:
Sag 2023-08-09 14:44:07 +02:00 committed by GitHub
parent cd013356f9
commit b95c8275f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 244 additions and 62 deletions

View file

@ -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;
}

View file

@ -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 (
<div className='gh-portal-account-wrapper'>
<AccountMain />

View file

@ -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);

View file

@ -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 (
<section>
<div className='gh-portal-section'>
<p
className='gh-portal-members-disabled-notification'
data-testid="members-disabled-notification-text"
>
{t('Memberships unavailable, contact the owner for access.')}
</p>
</div>
</section>
);
}
return (
<section>
<div className='gh-portal-section'>
@ -125,32 +144,52 @@ export default class SigninPage extends React.Component {
onKeyDown={(e, field) => this.onKeyDown(e, field)}
/>
</div>
<footer className='gh-portal-signin-footer'>
{this.renderSubmitButton()}
{this.renderSignupMessage()}
</footer>
</section>
);
}
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 (
<img className='gh-portal-signup-logo' src={siteLogo} alt={this.context.site.title} />
<img className='gh-portal-signup-logo' src={siteIcon} alt={this.context.site.title} />
);
} else if (!isSigninAllowed({site})) {
return (
<InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
);
}
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 (
<h1 className='gh-portal-main-title'>{siteTitle}</h1>
);
} else {
return (
<h1 className='gh-portal-main-title'>{t('Sign in')}</h1>
);
}
}
renderFormHeader() {
return (
<header className='gh-portal-signin-header'>
{this.renderSiteLogo()}
<h1 className="gh-portal-main-title">{t('Sign in')}</h1>
{this.renderSiteIcon()}
{this.renderSiteTitle()}
</header>
);
}
@ -158,19 +197,12 @@ export default class SigninPage extends React.Component {
render() {
return (
<>
{/* <div className='gh-portal-back-sitetitle'>
<SiteTitleBackButton />
</div> */}
<CloseButton />
<div className='gh-portal-logged-out-form-container'>
<div className='gh-portal-content signin'>
{this.renderFormHeader()}
{this.renderForm()}
</div>
<footer className='gh-portal-signin-footer'>
{this.renderSubmitButton()}
{this.renderSignupMessage()}
</footer>
</div>
</>
);

View file

@ -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(
<SigninPage />,
{
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();
});
});
});

View file

@ -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 (
<section>
<div className='gh-portal-section'>
<p
className='gh-portal-members-disabled-notification'
data-testid="members-disabled-notification-text"
>
{t('Memberships unavailable, contact the owner for access.')}
</p>
</div>
</section>
);
}
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 (
<img className='gh-portal-signup-logo' src={siteLogo} alt={site.title} />
<img className='gh-portal-signup-logo' src={siteIcon} alt={site.title} />
);
} else if (isInviteOnlySite({site, pageQuery})) {
return (
<InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
);
} else if (!isSignupAllowed({site})) {
return (
<InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
);
}
return null;
}
@ -741,7 +756,7 @@ class SignupPage extends React.Component {
const siteTitle = site.title || '';
return (
<header className='gh-portal-signup-header'>
{this.renderSiteLogo()}
{this.renderSiteIcon()}
<h1 className="gh-portal-main-title" data-testid='site-title-text'>{siteTitle}</h1>
</header>
);
@ -794,10 +809,6 @@ class SignupPage extends React.Component {
{this.renderFormHeader()}
{this.renderForm()}
</div>
{/* <footer className={'gh-portal-signup-footer gh-portal-logged-out-form-container ' + footerClass}>
{this.renderSubmitButton()}
{this.renderLoginMessage()}
</footer> */}
</>
);
}

View file

@ -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();
});
});
});

View file

@ -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});

View file

@ -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});

View file

@ -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: {

View file

@ -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 : '';