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:
parent
69c2af1ffe
commit
f5e1e6296c
18 changed files with 289 additions and 31 deletions
|
@ -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'
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
20
ghost/portal/src/components/pages/EmailSuppressedPage.css
Normal file
20
ghost/portal/src/components/pages/EmailSuppressedPage.css
Normal 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);
|
||||
}
|
63
ghost/portal/src/components/pages/EmailSuppressedPage.js
Normal file
63
ghost/portal/src/components/pages/EmailSuppressedPage.js
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
4
ghost/portal/src/images/icons/email-delivery-failed.svg
Normal file
4
ghost/portal/src/images/icons/email-delivery-failed.svg
Normal 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 |
|
@ -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 */
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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}));
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Add table
Reference in a new issue