0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Add suppression modal to Portal

closes TryGhost/Team#2256
- Users can remove themselves from the suppression list from the profile in Portal.
This commit is contained in:
e.baidakova 2022-11-22 15:36:15 +04:00 committed by Elena Baidakova
parent 69c2af1ffe
commit f5e1e6296c
18 changed files with 289 additions and 31 deletions

View file

@ -1,21 +1,27 @@
const {AbstractEmailSuppressionList, EmailSuppressionData} = require('@tryghost/email-suppression-list');
class InMemoryEmailSuppressionList extends AbstractEmailSuppressionList {
store = ['spam@member.test', 'fail@member.test'];
async removeEmail(email) {
if (email === 'fail@member.test') {
return false;
if ((email === 'fail@member.test' || email === 'spam@member.test') && this.store.includes(email)) {
this.store = this.store.filter(el => el !== email);
setTimeout(() => this.store.push(email), 3000);
return true;
}
return true;
return false;
}
async getSuppressionData(email) {
if (email === 'spam@member.test') {
if (email === 'spam@member.test' && this.store.includes(email)) {
return new EmailSuppressionData(true, {
timestamp: new Date(),
reason: 'spam'
});
}
if (email === 'fail@member.test') {
if (email === 'fail@member.test' && this.store.includes(email)) {
return new EmailSuppressionData(true, {
timestamp: new Date(),
reason: 'fail'

View file

@ -301,6 +301,29 @@ async function updateNewsletterPreference({data, state, api}) {
}
}
async function removeEmailFromSuppressionList({state, api}) {
try {
await api.member.deleteSuppression();
const action = 'removeEmailFromSuppressionList:success';
return {
action,
popupNotification: createPopupNotification({
type: 'removeEmailFromSuppressionList:success', autoHide: true, closeable: true, state, status: 'success',
message: 'You have been successfully resubscribed'
})
};
} catch (e) {
return {
action: 'removeEmailFromSuppressionList:failed',
popupNotification: createPopupNotification({
type: 'removeEmailFromSuppressionList:failed',
autoHide: true, closeable: true, state, status: 'error',
message: 'Your email has failed to resubscribe, please try again'
})
};
}
}
async function updateNewsletter({data, state, api}) {
try {
const {subscribed} = data;
@ -377,14 +400,16 @@ async function refreshMemberData({state, api}) {
if (member) {
return {
member,
success: true
success: true,
action: 'refreshMemberData:success'
};
}
return null;
} catch (err) {
return {
success: false,
error: err
error: err,
action: 'refreshMemberData:failed'
};
}
}
@ -469,7 +494,8 @@ const Actions = {
editBilling,
checkoutPlan,
updateNewsletterPreference,
showPopupNotification
showPopupNotification,
removeEmailFromSuppressionList
};
/** Handle actions in the App, returns updated state */

View file

@ -18,6 +18,7 @@ import {MagicLinkStyles} from './pages/MagicLinkPage';
import {PopupNotificationStyles} from './common/PopupNotification';
import {OfferPageStyles} from './pages/OfferPage';
import {FeedbackPageStyles} from './pages/FeedbackPage';
import EmailSuppressedPage from '!!raw-loader!./pages/EmailSuppressedPage.css';
// Global styles
const FrameStyles = `
@ -1173,6 +1174,7 @@ export function getFrameStyles({site}) {
PopupNotificationStyles +
MobileStyles +
MultipleProductsGlobalStyles +
FeedbackPageStyles;
FeedbackPageStyles +
EmailSuppressedPage;
return FrameStyle;
}

View file

@ -65,6 +65,21 @@ footer.gh-portal-account-footer {
margin: 24px 0 32px;
}
.gh-portal-list-detail .gh-portal-email-notice {
display: flex;
align-items: center;
gap: 5px;
margin-top: 6px;
color: var(--red);
font-weight: 500;
font-size: 1.25rem;
}
.gh-portal-email-notice-icon {
width: 20px;
height: 20px;
}
.gh-portal-billing-button-loader {
width: 32px;
height: 32px;

View file

@ -1,23 +1,32 @@
import React from 'react';
import {render, fireEvent} from 'utils/test-utils';
import AccountHomePage from './AccountHomePage';
import {member} from 'utils/test-fixtures';
import {site} from 'utils/fixtures';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<AccountHomePage />
<AccountHomePage />,
{
overrideContext: {
...overrides
}
}
);
const logoutBtn = utils.queryByRole('button', {name: 'logout'});
return {
logoutBtn,
mockOnActionFn,
...utils
utils
};
};
describe('Account Home Page', () => {
test('renders', () => {
const {logoutBtn} = setup();
const {logoutBtn, utils} = setup();
expect(logoutBtn).toBeInTheDocument();
expect(utils.queryByText('You\'re currently not receiving emails')).not.toBeInTheDocument();
expect(utils.queryByText('Email newsletter')).toBeInTheDocument();
});
test('can call signout', () => {
@ -26,4 +35,29 @@ describe('Account Home Page', () => {
fireEvent.click(logoutBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('signout');
});
test('can show suppressed info', () => {
const {mockOnActionFn, utils} = setup({member: member.suppressed});
expect(utils.queryByText('You\'re currently not receiving emails')).toBeInTheDocument();
const manageBtn = utils.queryByRole('button', {name: 'Manage'});
expect(manageBtn).toBeInTheDocument();
fireEvent.click(manageBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {lastPage: 'accountHome', page: 'emailSuppressed'});
});
test('can show Manage button for few newsletters', () => {
const {mockOnActionFn, utils} = setup({site: site});
expect(utils.queryByText('Update your preferences')).toBeInTheDocument();
expect(utils.queryByText('You\'re currently not receiving emails')).not.toBeInTheDocument();
const manageBtn = utils.queryByRole('button', {name: 'Manage'});
expect(manageBtn).toBeInTheDocument();
fireEvent.click(manageBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {lastPage: 'accountHome', page: 'accountEmail'});
});
});

View file

@ -1,12 +1,13 @@
import AppContext from 'AppContext';
import {useContext} from 'react';
import {hasCommentsEnabled, hasMultipleNewsletters, isEmailSuppressed} from 'utils/helpers';
import PaidAccountActions from './PaidAccountActions';
import EmailNewsletterAction from './EmailNewsletterAction';
import EmailPreferencesAction from './EmailPreferencesAction';
const AccountActions = () => {
const {member, onAction} = useContext(AppContext);
const {member, onAction, site} = useContext(AppContext);
const {name, email} = member;
const openEditProfile = () => {
@ -16,6 +17,8 @@ const AccountActions = () => {
});
};
const showEmailPreferences = hasMultipleNewsletters({site}) || hasCommentsEnabled({site}) || isEmailSuppressed({member});
return (
<div>
<div className='gh-portal-list'>
@ -28,8 +31,12 @@ const AccountActions = () => {
</section>
<PaidAccountActions />
<EmailPreferencesAction />
<EmailNewsletterAction />
{
showEmailPreferences
? <EmailPreferencesAction />
: <EmailNewsletterAction />
}
</div>
{/* <ProductList openUpdatePlan={openUpdatePlan}></ProductList> */}
</div>

View file

@ -1,15 +1,12 @@
import AppContext from 'AppContext';
import Switch from 'components/common/Switch';
import {getSiteNewsletters, hasCommentsEnabled, hasMultipleNewsletters} from 'utils/helpers';
import {getSiteNewsletters} from 'utils/helpers';
import {useContext} from 'react';
function EmailNewsletterAction() {
const {member, site, onAction} = useContext(AppContext);
let {newsletters} = member;
if (hasMultipleNewsletters({site}) || hasCommentsEnabled({site})) {
return null;
}
const subscribed = !!newsletters?.length;
let label = subscribed ? 'Subscribed' : 'Unsubscribed';
const onToggleSubscription = (e, sub) => {

View file

@ -1,21 +1,31 @@
import AppContext from 'AppContext';
import {hasCommentsEnabled, hasMultipleNewsletters} from 'utils/helpers';
import {useContext} from 'react';
import {isEmailSuppressed} from 'utils/helpers';
import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg';
function EmailPreferencesAction() {
const {site, onAction} = useContext(AppContext);
if (!hasMultipleNewsletters({site}) && !hasCommentsEnabled({site})) {
return null;
}
const {onAction, member} = useContext(AppContext);
const emailSuppressed = isEmailSuppressed({member});
const page = emailSuppressed ? 'emailSuppressed' : 'accountEmail';
return (
<section>
<div className='gh-portal-list-detail'>
<h3>Emails</h3>
<p>Update your preferences</p>
{
emailSuppressed
? (
<p className="gh-portal-email-notice">
<EmailDeliveryFailedIcon className="gh-portal-email-notice-icon" />
You're currently not receiving emails
</p>
)
: <p>Update your preferences</p>
}
</div>
<button className='gh-portal-btn gh-portal-btn-list' onClick={(e) => {
onAction('switchPage', {
page: 'accountEmail',
page,
lastPage: 'accountHome'
});
}}>Manage</button>

View file

@ -0,0 +1,20 @@
.gh-email-suppressed-page-title {
margin-bottom: 14px;
}
.gh-email-suppressed-page-icon {
display: block;
width: 38px;
height: 38px;
margin: 0 auto 18px;
}
.gh-email-suppressed-page-text {
padding: 0 14px;
text-align: center;
color: var(--grey6);
}
.gh-email-suppressed-page-text a {
color: var(--grey3);
}

View file

@ -0,0 +1,63 @@
import AppContext from 'AppContext';
import {useContext, useEffect} from 'react';
import CloseButton from 'components/common/CloseButton';
import BackButton from 'components/common/BackButton';
import ActionButton from 'components/common/ActionButton';
import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg';
export default function EmailSuppressedPage() {
const {brandColor, lastPage, onAction, action} = useContext(AppContext);
useEffect(() => {
if (['removeEmailFromSuppressionList:success'].includes(action)) {
onAction('refreshMemberData');
}
if (['removeEmailFromSuppressionList:failed', 'refreshMemberData:success', 'refreshMemberData:failed'].includes(action)) {
onAction('back');
}
}, [action, onAction]);
const isRunning = ['removeEmailFromSuppressionList:running', 'refreshMemberData:running'].includes(action);
const handleSubmit = () => {
onAction('removeEmailFromSuppressionList');
};
return (
<div className="gh-email-suppressed-page">
<header className='gh-portal-detail-header'>
<BackButton brandColor={brandColor} hidden={!lastPage} onClick={() => {
onAction('back');
}} />
<CloseButton />
</header>
<EmailDeliveryFailedIcon className="gh-email-suppressed-page-icon" />
<div className="gh-email-suppressed-page-text">
<h3 className="gh-portal-main-title gh-email-suppressed-page-title">Email disabled</h3>
<p>
All newsletters have been disabled on your account. <br/> This can happen due to a spam complaint or
permanent failure (bounce).
</p>
<a href="https://ghost.org" target="_blank" rel="noopener noreferrer" onClick={() => {
window.open('https://ghost.org', '_blank');
}}>
Learn more about why this happens
</a>
</div>
<ActionButton
classes="gh-portal-confirm-button"
onClick={handleSubmit}
disabled={isRunning}
brandColor={brandColor}
label="Resubscribe your email"
isRunning={isRunning}
/>
</div>
);
}

View file

@ -0,0 +1,33 @@
import React from 'react';
import {render, fireEvent} from 'utils/test-utils';
import EmailSuppressedPage from './EmailSuppressedPage';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<EmailSuppressedPage />
);
const resubscribeBtn = utils.queryByRole('button', {name: 'Resubscribe your email'});
const title = utils.queryByText('Email disabled');
return {
resubscribeBtn,
title,
mockOnActionFn,
...utils
};
};
describe('Email Suppressed Page', () => {
test('renders', () => {
const {resubscribeBtn, title} = setup();
expect(title).toBeInTheDocument();
expect(resubscribeBtn).toBeInTheDocument();
});
test('can call resubscribe button', () => {
const {mockOnActionFn, resubscribeBtn} = setup();
fireEvent.click(resubscribeBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('removeEmailFromSuppressionList');
});
});

View file

@ -0,0 +1,4 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="m12.79 12.376 4.08 4.079m-4.08 0 4.08-4.08" stroke="red" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m10.439 17.994-2.664-2.652-2.86 1.478.111-4.239m0 0L1.677 9.232a1.344 1.344 0 0 1-.366-1.222 1.369 1.369 0 0 1 .904-1.05l13.111-4.374a1.369 1.369 0 0 1 1.76 1.758L15 10m-9.974 2.581 11.67-9.727" stroke="#A3A3A3" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

View file

@ -10,6 +10,7 @@ import OfferPage from './components/pages/OfferPage';
import NewsletterSelectionPage from './components/pages/NewsletterSelectionPage';
import UnsubscribePage from './components/pages/UnsubscribePage';
import FeedbackPage from './components/pages/FeedbackPage';
import EmailSuppressedPage from './components/pages/EmailSuppressedPage';
/** List of all available pages in Portal, mapped to their UI component
* Any new page added to portal needs to be mapped here
@ -26,7 +27,8 @@ const Pages = {
magiclink: MagicLinkPage,
loading: LoadingPage,
offer: OfferPage,
feedback: FeedbackPage
feedback: FeedbackPage,
emailSuppressed: EmailSuppressedPage
};
/** Return page if valid, fallback to signup */

View file

@ -199,6 +199,20 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
});
},
deleteSuppression() {
const url = endpointFor({type: 'members', resource: 'member/suppression'});
return makeRequest({
url,
method: 'DELETE'
}).then(function (res) {
if (!res.ok) {
throw new Error('Your email has failed to resubscribe, please try again');
}
return true;
});
},
async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters}) {
const url = endpointFor({type: 'members', resource: 'send-magic-link'});
const body = {

View file

@ -113,7 +113,11 @@ export function getMemberData({
subscriptions = [],
paid = false,
avatarImage: avatar_image = '',
subscribed = true
subscribed = true,
email_suppression = {
suppressed: false,
info: null
}
} = {}) {
return {
uuid: `member_${objectId()}`,
@ -123,7 +127,8 @@ export function getMemberData({
paid,
subscribed,
avatar_image,
subscriptions
subscriptions,
email_suppression
};
}

View file

@ -497,6 +497,10 @@ export function hasMultipleNewsletters({site}) {
return newsletters?.length > 1;
}
export function isEmailSuppressed({member}) {
return member?.email_suppression?.suppressed;
}
export function hasOnlyFreeProduct({site}) {
const products = getSiteProducts({site});
return (products.length === 1 && hasFreeProductPrice({site}));

View file

@ -189,6 +189,22 @@ export const member = {
avatarImage: '',
subscribed: true
}),
suppressed: getMemberData({
name: 'Jamie Larson',
email: 'jamie@example.com',
firstname: 'Jamie',
subscriptions: [],
paid: false,
avatarImage: '',
subscribed: true,
email_suppression: {
suppressed: true,
info: {
reason: 'spam',
timestamp: '2022-11-23T09:54:06.210Z'
}
}
}),
paid: getMemberData({
paid: true,
subscriptions: [

View file

@ -16765,7 +16765,7 @@ jest-pnp-resolver@^1.2.2:
resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c"
integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==
jest-raw-loader@^1.0.1:
jest-raw-loader@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz#ce9f56d54650f157c4a7d16d224ba5d613bcd626"
integrity sha512-g9oaAjeC4/rIJk1Wd3RxVbOfMizowM7LSjEJqa4R9qDX0OjQNABXOhH+GaznUp+DjTGVPi2vPPbQXyX87DOnYg==
@ -22206,7 +22206,7 @@ raw-body@~1.1.0:
bytes "1"
string_decoder "0.10"
raw-loader@^4.0.2:
raw-loader@4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6"
integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==