0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Cleaned up notification flows

no issue

- Adds success and error notification messages for different actions
- Cleans up notification flows and messages
- Adds new helpers for members and site
- Updates actions for email/name update
This commit is contained in:
Rish 2020-09-28 11:48:48 +05:30
parent 160e5e6e7d
commit 041de9f045
10 changed files with 336 additions and 184 deletions

View file

@ -8,6 +8,8 @@ import {getActivePage, isAccountPage} from './pages';
import * as Fixtures from './utils/fixtures';
import ActionHandler from './actions';
import './App.css';
import NotificationParser from './utils/notifications';
import {createPopupNotification} from './utils/helpers';
const React = require('react');
const DEV_MODE_DATA = {
@ -94,12 +96,13 @@ export default class App extends React.Component {
async initSetup() {
try {
// Fetch data from API, links, preview, dev sources
const {site, member, page, showPopup} = await this.fetchData();
const {site, member, page, showPopup, popupNotification} = await this.fetchData();
this.setState({
site,
member,
page,
showPopup,
popupNotification,
action: 'init:success',
initStatus: 'success'
});
@ -126,14 +129,15 @@ export default class App extends React.Component {
const {site: devSiteData, ...restDevData} = this.fetchDevData();
const {site: linkSiteData, ...restLinkData} = this.fetchLinkData();
const {site: previewSiteData, ...restPreviewData} = this.fetchPreviewData();
const {site: notificationSiteData, ...restNotificationData} = this.fetchNotificationData();
const stripeParam = this.getStripeUrlParam();
// const stripeParam = this.getStripeUrlParam();
let page = '';
/** Set page for magic link popup on stripe success*/
if (!member && stripeParam === 'success') {
page = 'magiclink';
}
// /** Set page for magic link popup on stripe success*/
// if (!member && stripeParam === 'success') {
// page = 'magiclink';
// }
return {
member,
@ -142,10 +146,12 @@ export default class App extends React.Component {
...apiSiteData,
...linkSiteData,
...previewSiteData,
...notificationSiteData,
...devSiteData
},
...restDevData,
...restLinkData,
...restNotificationData,
...restPreviewData
};
}
@ -199,6 +205,18 @@ export default class App extends React.Component {
return data;
}
fetchNotificationData() {
const {type, status, duration, autoHide, closeable} = NotificationParser({billingOnly: true}) || {};
if (['stripe:billing-update'].includes(type)) {
const popupNotification = createPopupNotification({type, status, duration, closeable, autoHide, state: this.state});
return {
showPopup: true,
popupNotification
};
}
return {};
}
/** Fetch state from Portal Links */
fetchLinkData() {
const [path] = window.location.hash.substr(1).split('?');
@ -256,9 +274,17 @@ export default class App extends React.Component {
action: ''
});
}, 2000);
} catch (e) {
} catch (error) {
const popupNotification = createPopupNotification({
type: `${action}:failed`,
autoHide: true, closeable: true, status: 'error', state: this.state,
meta: {
error
}
});
this.setState({
action: `${action}:failed`
action: `${action}:failed`,
popupNotification
});
}
}

View file

@ -1,20 +1,9 @@
function createPopupNotification({type, status, autoHide, closeable, state}) {
let count = 0;
if (state.popupNotification) {
count = (state.popupNotification.count || 0) + 1;
}
return {
type,
status,
autoHide,
closeable,
count
};
}
import {createPopupNotification, getMemberEmail, getMemberName} from './utils/helpers';
function switchPage({data}) {
return {
page: data.page,
popupNotification: null,
lastPage: data.lastPage || null
};
}
@ -64,14 +53,24 @@ function closeNotification({state}) {
};
}
async function signout({api}) {
await api.member.signout();
return {
action: 'signout:success'
};
async function signout({api, state}) {
try {
await api.member.signout();
return {
action: 'signout:success'
};
} catch (e) {
return {
action: 'signout:failed',
popupNotification: createPopupNotification({
type: 'signout:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to logout, please try again.'
})
};
}
}
async function signin({data, api}) {
async function signin({data, api, state}) {
try {
await api.member.sendMagicLink(data);
return {
@ -79,20 +78,16 @@ async function signin({data, api}) {
};
} catch (e) {
return {
action: 'signin:failed',
popupNotification: createPopupNotification({
type: 'signin:failed',
autoHide: false,
closeable: true,
status: 'error',
meta: {
reason: e.message
}
type: 'signin:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to login, please try again.'
})
};
}
}
async function signup({data, api}) {
async function signup({data, state, api}) {
try {
const {plan, email, name} = data;
if (plan.toLowerCase() === 'free') {
@ -105,82 +100,95 @@ async function signup({data, api}) {
};
} catch (e) {
return {
action: 'signup:failed',
popupNotification: createPopupNotification({
type: 'signup:failed',
autoHide: false,
closeable: true,
status: 'error',
meta: {
reason: e.message
}
type: 'signup:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to signup, please try again.'
})
};
}
}
async function updateEmail({data, api}) {
async function checkoutPlan({data, state, api}) {
try {
await api.member.sendMagicLink(data);
return {
action: 'updateEmail:success'
};
const {plan} = data;
await api.member.checkoutPlan({
plan
});
} catch (e) {
return {
action: 'checkoutPlan:failed',
popupNotification: createPopupNotification({
type: 'updateEmail:failed',
autoHide: false,
closeable: true,
status: 'error',
meta: {
reason: e.message
}
type: 'checkoutPlan:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to checkout plan, please try again.'
})
};
}
}
async function checkoutPlan({data, api}) {
const {plan} = data;
await api.member.checkoutPlan({
plan
});
}
async function updateSubscription({data, state, api}) {
const {plan, subscriptionId, cancelAtPeriodEnd} = data;
await api.member.updateSubscription({
planName: plan, subscriptionId, cancelAtPeriodEnd
});
const member = await api.member.sessionData();
const action = 'updateSubscription:success';
return {
action,
popupNotification: createPopupNotification({
type: action,
autoHide: true,
closeable: true,
state
}),
page: 'accountHome',
member: member
};
try {
const {plan, subscriptionId, cancelAtPeriodEnd} = data;
await api.member.updateSubscription({
planName: plan, subscriptionId, cancelAtPeriodEnd
});
const member = await api.member.sessionData();
const action = 'updateSubscription:success';
return {
action,
popupNotification: createPopupNotification({
type: action, autoHide: true, closeable: true, state, status: 'success',
message: 'Subscription plan successfully updated'
}),
page: 'accountHome',
member: member
};
} catch (e) {
return {
action: 'updateSubscription:failed',
popupNotification: createPopupNotification({
type: 'updateSubscription:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to update subscription.'
})
};
}
}
async function cancelSubscription({data, api}) {
const {subscriptionId, cancelAtPeriodEnd} = data;
await api.member.updateSubscription({
subscriptionId, cancelAtPeriodEnd
});
const member = await api.member.sessionData();
return {
action: 'cancelSubscription:success',
page: 'accountHome',
member: member
};
async function cancelSubscription({data, state, api}) {
try {
const {subscriptionId, cancelAtPeriodEnd} = data;
await api.member.updateSubscription({
subscriptionId, cancelAtPeriodEnd
});
const member = await api.member.sessionData();
return {
action: 'cancelSubscription:success',
page: 'accountHome',
member: member
};
} catch (e) {
return {
action: 'cancelSubscription:failed',
popupNotification: createPopupNotification({
type: 'cancelSubscription:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to cancel subscription.'
})
};
}
}
async function editBilling({data, api}) {
await api.member.editBilling();
async function editBilling({data, state, api}) {
try {
await api.member.editBilling();
} catch (e) {
return {
action: 'editBilling:failed',
popupNotification: createPopupNotification({
type: 'editBilling:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to update billing information.'
})
};
}
}
async function clearPopupNotification() {
@ -190,57 +198,128 @@ async function clearPopupNotification() {
}
async function updateNewsletter({data, state, api}) {
const {subscribed} = data;
const member = await api.member.update({subscribed});
if (!member) {
return {
action: 'updateNewsletter:failed'
};
} else {
try {
const {subscribed} = data;
const member = await api.member.update({subscribed});
if (!member) {
throw new Error('Failed to update newsletter');
}
const action = 'updateNewsletter:success';
return {
action,
member: member,
popupNotification: createPopupNotification({
type: action,
autoHide: true,
closeable: true,
state
type: action, autoHide: true, closeable: true, state, status: 'success',
message: 'Newsletter settings updated'
})
};
} catch (e) {
return {
action: 'updateNewsletter:failed',
popupNotification: createPopupNotification({
type: 'updateNewsletter:failed', autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to update newsletter settings'
})
};
}
}
async function updateMemberEmail({data, state, api}) {
const {email} = data;
const originalEmail = getMemberEmail({member: state.member});
if (email !== originalEmail) {
try {
await api.member.sendMagicLink({email, oldEmail: originalEmail, emailType: 'updateEmail'});
return {
success: true
};
} catch (err) {
return {
success: false,
error: err
};
}
}
return null;
}
async function updateMemberData({data, state, api}) {
const {name} = data;
const originalName = getMemberName({member: state.member});
if (originalName !== name) {
try {
const member = await api.member.update({name});
if (!member) {
throw new Error('Failed to update member');
}
return {
member,
success: true
};
} catch (err) {
return {
success: false,
error: err
};
}
}
return null;
}
async function updateProfile({data, state, api}) {
const {name, subscribed} = data;
const member = await api.member.update({name, subscribed});
if (!member) {
const action = 'updateProfile:failed';
const [dataUpdate, emailUpdate] = await Promise.all([updateMemberData({data, state, api}), updateMemberEmail({data, state, api})]);
if (dataUpdate && emailUpdate) {
if (emailUpdate.success) {
return {
action: 'updateProfile:success',
...(dataUpdate.success ? {member: dataUpdate.member} : {}),
page: 'accountHome',
popupNotification: createPopupNotification({
type: 'updateProfile:success', autoHide: true, closeable: true, status: 'success', state,
message: 'Check your inbox to verify email update'
})
};
}
const message = !dataUpdate.success ? 'Failed to update account data' : 'Failed to send verification email';
return {
action,
action: 'updateProfile:failed',
...(dataUpdate.success ? {member: dataUpdate.member} : {}),
popupNotification: createPopupNotification({
type: action,
autoHide: true,
closeable: true,
status: 'error',
state
type: 'updateProfile:failed', autoHide: true, closeable: true, status: 'error', message, state
})
};
} else {
const action = 'updateProfile:success';
} else if (dataUpdate) {
const action = dataUpdate.success ? 'updateProfile:success' : 'updateProfile:failed';
const status = dataUpdate.success ? 'success' : 'error';
const message = !dataUpdate.success ? 'Failed to update account data' : 'Account data successfully updated';
return {
action,
member: member,
...(dataUpdate.success ? {member: dataUpdate.member} : {}),
page: 'accountHome',
popupNotification: createPopupNotification({
type: action,
autoHide: true,
closeable: true,
status: 'success',
state
type: action, autoHide: true, closeable: true, status, state, message
})
};
} else if (emailUpdate) {
const action = emailUpdate.success ? 'updateProfile:success' : 'updateProfile:failed';
const status = emailUpdate.success ? 'success' : 'error';
const message = !emailUpdate.success ? 'Failed to send verification email' : 'Check your inbox to verify email update';
return {
action,
popupNotification: createPopupNotification({
type: action, autoHide: true, closeable: true, status, state, message
})
};
}
return {
action: 'updateProfile:success',
page: 'accountHome',
popupNotification: createPopupNotification({
type: 'updateProfile:success', autoHide: true, closeable: true, status: 'success', state,
message: 'Account data successfully updated'
})
};
}
const Actions = {
@ -254,7 +333,6 @@ const Actions = {
signout,
signin,
signup,
updateEmail,
updateSubscription,
cancelSubscription,
updateNewsletter,

View file

@ -3,7 +3,7 @@ import AppContext from '../AppContext';
import NotificationStyle from './Notification.styles';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
import NotificationParser, {clearURLParams} from '../utils/notifications';
import { getPortalLink } from '../utils/helpers';
import {getPortalLink} from '../utils/helpers';
const React = require('react');
@ -52,15 +52,16 @@ const NotificationText = ({type, status, context}) => {
</p>
);
} else if (type === 'stripe:checkout' && status === 'success') {
if (context.member) {
return (
<p>
Success! Your account is fully activated, you now have access to all content.
</p>
);
}
return (
<p>
Success! Your account is fully activated, you now have access to all content.
</p>
);
} else if (type === 'stripe:billing-update' && status === 'success') {
return (
<p>
You've successfully updated your billing information
Success! Check your email for magic link to sign-in.
</p>
);
}
@ -151,16 +152,14 @@ export default class Notification extends React.Component {
}
onHideNotification() {
const qs = window.location.search || '';
const qsParams = new URLSearchParams(qs);
const type = this.state.type;
const deleteParams = [];
if (['signin', 'signout'].includes(type)) {
if (['signin', 'signup'].includes(type)) {
deleteParams.push('action', 'success');
} else if (['stripe:checkout', 'stripe:billing-update'].includes(type)) {
} else if (['stripe:checkout'].includes(type)) {
deleteParams.push('stripe');
}
clearURLParams(qsParams, deleteParams);
clearURLParams(deleteParams);
this.setState({
active: false
});

View file

@ -1,6 +1,8 @@
import React from 'react';
import AppContext from '../../AppContext';
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
import {getSupportAddress} from '../../utils/helpers';
import {clearURLParams} from '../../utils/notifications';
export const PopupNotificationStyles = `
.gh-portal-popupnotification {
@ -59,6 +61,14 @@ export const PopupNotificationStyles = `
background: var(--red);
}
.gh-portal-popupnotification.warning {
background: var(--yellow);
}
.gh-portal-popupnotification.warning p, .gh-portal-popupnotification.warning .closeicon {
color: var(--grey1);
}
@keyframes popupnotification-slidein {
0% { transform: translateY(-100px); }
60% { transform: translateY(8px); }
@ -77,31 +87,20 @@ const CloseButton = ({hide = false, onClose}) => {
return null;
}
return (
<CloseIcon className='closeicon' alt='Close' onClick={onClose}/>
<CloseIcon className='closeicon' alt='Close' onClick={onClose} />
);
};
const NotificationText = ({type, status}) => {
if (type === 'updateNewsletter:success') {
const NotificationText = ({message, site}) => {
const supportAddress = getSupportAddress({site});
const supportAddressMail = `mailto:${supportAddress}`;
if (message) {
return (
<p> Newsletter settings updated</p>
);
} else if (type === 'updateSubscription:success') {
return (
<p> Subscription plan successfully updated</p>
);
} else if (type === 'updateProfile:success') {
return (
<p>Profile updated</p>
);
} else if (type === 'updateProfile:failed') {
return (
<p>Failed to update profile</p>
<p>{message}</p>
);
}
const label = status === 'success' ? 'Success' : 'Failed';
return (
<p> ${label}</p>
<p> An unexpected error occured. Please try again or <a href={supportAddressMail}>contact support</a> if the error persists. </p>
);
};
@ -110,13 +109,17 @@ export default class PopupNotification extends React.Component {
constructor() {
super();
this.state = {
className: '',
notificationType: ''
className: ''
};
}
onAnimationEnd(e) {
const {popupNotification} = this.context;
const {type} = popupNotification || {};
if (e.animationName === 'popupnotification-slideout') {
if (type === 'stripe:billing-update') {
clearURLParams(['stripe']);
}
this.context.onAction('clearPopupNotification');
}
}
@ -129,7 +132,7 @@ export default class PopupNotification extends React.Component {
componentDidUpdate() {
const {popupNotification} = this.context;
if (popupNotification.count !== this.state.count) {
if (popupNotification.count !== this.state.notificationCount) {
clearTimeout(this.timeoutId);
this.handlePopupNotification({popupNotification});
}
@ -144,7 +147,7 @@ export default class PopupNotification extends React.Component {
return {
className: 'slideout',
notificationCount: popupNotification.count
}
};
}
return {};
});
@ -166,15 +169,15 @@ export default class PopupNotification extends React.Component {
}
render() {
const {popupNotification} = this.context;
const {popupNotification, site} = this.context;
const {className} = this.state;
const {type, status, closeable} = popupNotification;
const statusClass = status ? ` ${status}` : '';
const {type, status, closeable, message} = popupNotification;
const statusClass = status ? ` ${status}` : '';
const slideClass = className ? ` ${className}` : '';
return (
<div className={`gh-portal-popupnotification${statusClass}${slideClass}`} onAnimationEnd={e => this.onAnimationEnd(e)}>
<NotificationText type={type} status={status} />
<NotificationText type={type} status={status} message={message} site={site} />
<CloseButton hide={!closeable} onClose={e => this.closeNotification(e)}/>
</div>
);

View file

@ -46,12 +46,8 @@ export default class AccountProfilePage extends React.Component {
};
}, () => {
const {email, name, errors} = this.state;
const originalEmail = this.context.member.email;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
if (email !== originalEmail) {
this.context.onAction('updateEmail', {email, oldEmail: originalEmail, emailType: 'updateEmail'});
}
this.context.onAction('updateProfile', {email, name});
}
});

View file

@ -72,11 +72,18 @@ export default class SigninPage extends React.Component {
}
renderSubmitButton() {
const isRunning = (this.context.action === 'signin:running');
const label = isRunning ? 'Sending login link...' : 'Send login link';
const {action} = this.context;
let retry = false;
const isRunning = (action === 'signin:running');
let label = isRunning ? 'Sending login link...' : 'Send login link';
const disabled = isRunning ? true : false;
if (action === 'signin:failed') {
label = 'Retry';
retry = true;
}
return (
<ActionButton
retry={retry}
style={{width: '100%'}}
onClick={e => this.handleSignin(e)}
disabled={disabled}

View file

@ -6,7 +6,6 @@ import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
import CalculateDiscount from '../../utils/discount';
import {getSitePlans, hasOnlyFreePlan} from '../../utils/helpers';
// import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
const React = require('react');

View file

@ -15,7 +15,7 @@ export const site = {
is_stripe_configured: true,
portal_button: true,
portal_name: true,
portal_plans: ['monthly', 'yearly'],
portal_plans: ['free','monthly', 'yearly'],
portal_button_icon: 'icon-1',
portal_button_signup_text: 'Subscribe now',
portal_button_style: 'icon-and-text',

View file

@ -2,12 +2,12 @@ import CalculateDiscount from './discount';
export function getPortalLinkPath({page}) {
const Links = {
'default': '#/portal',
'signin': '#/portal/signin',
'signup': '#/portal/signup',
'account': '#/portal/account',
default: '#/portal',
signin: '#/portal/signin',
signup: '#/portal/signup',
account: '#/portal/account',
'account-plans': '#/portal/account/plans',
'account-profile': '#/portal/account/profile',
'account-profile': '#/portal/account/profile'
};
if (Object.keys(Links).includes(page)) {
return Links[page];
@ -124,3 +124,39 @@ export function getSitePlans({site = {}, includeFree = true}) {
}
return plansData;
}
export const getMemberEmail = ({member}) => {
if (!member) {
return '';
}
return member.email;
};
export const getMemberName = ({member}) => {
if (!member) {
return '';
}
return member.name;
};
export const getSupportAddress = ({site}) => {
const {members_support_address: supportAddress} = site || {};
return supportAddress || '';
};
export const createPopupNotification = ({type, status, autoHide, duration, closeable, state, message, meta}) => {
let count = 0;
if (state && state.popupNotification) {
count = (state.popupNotification.count || 0) + 1;
}
return {
type,
status,
autoHide,
closeable,
duration,
meta,
message,
count
};
};

View file

@ -1,4 +1,4 @@
export const handleAuthActions = ({action, status}) => {
export const handleAuthActions = ({qsParams, action, status}) => {
if (status && ['true', 'false'].includes(status)) {
const successStatus = JSON.parse(status);
return {
@ -11,8 +11,8 @@ export const handleAuthActions = ({action, status}) => {
return {};
};
export const handleStripeActions = ({status}) => {
if (['cancel', 'success'].includes(status)) {
export const handleStripeActions = ({qsParams, status, billingOnly}) => {
if (!billingOnly && ['cancel', 'success'].includes(status)) {
const statusVal = status === 'success' ? 'success' : 'warning';
return {
type: 'stripe:checkout',
@ -20,18 +20,23 @@ export const handleStripeActions = ({status}) => {
duration: 3000,
autoHide: true
};
} else if (['billing-update-success', 'billing-update-cancel'].includes(status)) {
}
if (billingOnly && ['billing-update-success', 'billing-update-cancel'].includes(status)) {
const statusVal = status === 'billing-update-success' ? 'success' : 'warning';
return {
type: 'stripe:billing-update',
status: statusVal,
duration: 3000,
autoHide: true
autoHide: true,
closeable: true
};
}
};
export const clearURLParams = (qsParams, paramsToClear = []) => {
export const clearURLParams = (paramsToClear = []) => {
const qs = window.location.search || '';
const qsParams = new URLSearchParams(qs);
paramsToClear.forEach((param) => {
qsParams.delete(param);
});
@ -40,7 +45,7 @@ export const clearURLParams = (qsParams, paramsToClear = []) => {
};
/** Handle actions in the App, returns updated state */
export default function NotificationParser() {
export default function NotificationParser({billingOnly = false} = {}) {
const qs = window.location.search;
if (!qs) {
return null;
@ -50,10 +55,13 @@ export default function NotificationParser() {
const successStatus = qsParams.get('success');
const stripeStatus = qsParams.get('stripe');
let notificationData = null;
if (action && successStatus) {
return handleAuthActions({action, status: successStatus});
} else if (stripeStatus) {
return handleStripeActions({status: stripeStatus});
if (stripeStatus) {
return handleStripeActions({qsParams, status: stripeStatus, billingOnly});
}
if (action && successStatus && !billingOnly) {
return handleAuthActions({qsParams, action, status: successStatus});
}
return notificationData;