Ghost/ghost/portal/src/App.js
Rishabh 63ca3a00f3 Added first version for offer redirects
refs https://github.com/TryGhost/Team/issues/1086

- fires stripe checkout for new Portal link for offers - `/#/portal/offers/OFFER_ID` as prototype
2021-09-28 16:43:56 +05:30

703 lines
26 KiB
JavaScript

import * as Sentry from '@sentry/react';
import TriggerButton from './components/TriggerButton';
import Notification from './components/Notification';
import PopupModal from './components/PopupModal';
import setupGhostApi from './utils/api';
import AppContext from './AppContext';
import {hasMode} from './utils/check-mode';
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, getAvailablePrices, getCurrencySymbol, getFirstpromoterId, getProductFromId, getQueryPrice, getSiteDomain, isComplimentaryMember, isInviteOnlySite, isSentryEventAllowed, removePortalLinkFromUrl} from './utils/helpers';
const handleDataAttributes = require('./data-attributes');
const React = require('react');
const DEV_MODE_DATA = {
showPopup: true,
site: Fixtures.site,
member: Fixtures.member.paid,
page: 'signup'
};
function SentryErrorBoundary({site, children}) {
const {portal_sentry: portalSentry} = site || {};
if (portalSentry && portalSentry.dsn) {
return (
<Sentry.ErrorBoundary>
{children}
</Sentry.ErrorBoundary>
);
}
return (
<>
{children}
</>
);
}
export default class App extends React.Component {
constructor(props) {
super(props);
if (!props.testState) {
// Setup custom trigger button handling
this.setupCustomTriggerButton();
}
// testState is used by App.test to pass custom default state for testing
this.state = props.testState || {
site: null,
member: null,
page: 'loading',
showPopup: false,
action: 'init:running',
initStatus: 'running',
lastPage: null,
customSiteUrl: props.customSiteUrl
};
}
componentDidMount() {
/** Ignores API init when in test mode */
if (!this.props.testState) {
this.initSetup();
}
}
componentDidUpdate(prevProps, prevState) {
/**Handle custom trigger class change on popup open state change */
if (prevState.showPopup !== this.state.showPopup) {
this.handleCustomTriggerClassUpdate();
}
if (this.state.initStatus === 'success' && prevState.initStatus !== this.state.initStatus) {
const {siteUrl} = this.props;
const contextState = this.getContextFromState();
this.sendPortalReadyEvent();
handleDataAttributes({
siteUrl,
site: contextState.site,
member: contextState.member
});
}
}
componentWillUnmount() {
/**Clear timeouts and event listeners on unmount */
clearTimeout(this.timeoutId);
this.customTriggerButtons && this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.removeEventListener('click', this.clickHandler);
});
}
sendPortalReadyEvent() {
if (window.self !== window.parent) {
window.parent.postMessage({
type: 'portal-ready',
payload: {}
}, '*');
}
}
/** Setup custom trigger buttons handling on page */
setupCustomTriggerButton() {
// Handler for custom buttons
this.clickHandler = (event) => {
event.preventDefault();
const target = event.currentTarget;
const pagePath = (target && target.dataset.portal);
const {page, pageQuery} = this.getPageFromLinkPath(pagePath) || {};
if (this.state.initStatus === 'success') {
if (pageQuery && pageQuery !== 'free') {
this.handleSignupQuery({site: this.state.site, pageQuery});
} else {
this.dispatchAction('openPopup', {page, pageQuery});
}
}
};
const customTriggerSelector = '[data-portal]';
const popupCloseClass = 'gh-portal-close';
this.customTriggerButtons = document.querySelectorAll(customTriggerSelector) || [];
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.classList.add(popupCloseClass);
// Remove any existing event listener
customTriggerButton.removeEventListener('click', this.clickHandler);
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
/** Handle portal class set on custom trigger buttons */
handleCustomTriggerClassUpdate() {
const popupOpenClass = 'gh-portal-open';
const popupCloseClass = 'gh-portal-close';
this.customTriggerButtons.forEach((customButton) => {
const elAddClass = this.state.showPopup ? popupOpenClass : popupCloseClass;
const elRemoveClass = this.state.showPopup ? popupCloseClass : popupOpenClass;
customButton.classList.add(elAddClass);
customButton.classList.remove(elRemoveClass);
});
}
/** Initialize portal setup on load, fetch data and setup state*/
async initSetup() {
try {
// Fetch data from API, links, preview, dev sources
const {site, member, page, showPopup, popupNotification, lastPage, pageQuery} = await this.fetchData();
const state = {
site,
member,
page,
lastPage,
pageQuery,
showPopup,
popupNotification,
action: 'init:success',
initStatus: 'success'
};
this.handleSignupQuery({site, pageQuery});
this.setState(state);
// Listen to preview mode changes
this.hashHandler = () => {
this.updateStateForPreviewLinks();
};
window.addEventListener('hashchange', this.hashHandler, false);
} catch (e) {
/* eslint-disable no-console */
console.error(`[Portal] Failed to initialize:`, e);
/* eslint-enable no-console */
this.setState({
action: 'init:failed',
initStatus: 'failed'
});
}
}
/** Fetch state data from all available sources */
async fetchData() {
const {site: apiSiteData, member} = await this.fetchApiData();
const {site: devSiteData, ...restDevData} = this.fetchDevData();
const {site: linkSiteData, ...restLinkData} = this.fetchLinkData();
const {site: previewSiteData, ...restPreviewData} = this.fetchPreviewData();
const {site: notificationSiteData, ...restNotificationData} = this.fetchNotificationData();
let page = '';
return {
member,
page,
site: {
...apiSiteData,
...linkSiteData,
...previewSiteData,
...notificationSiteData,
...devSiteData,
plans: {
...(devSiteData || {}).plans,
...(apiSiteData || {}).plans,
...(previewSiteData || {}).plans
}
},
...restDevData,
...restLinkData,
...restNotificationData,
...restPreviewData
};
}
/** Fetch state for Dev mode */
fetchDevData() {
// Setup custom dev mode data from fixtures
if (hasMode(['dev']) && !this.state.customSiteUrl) {
return DEV_MODE_DATA;
}
return {};
}
/** Fetch state from Preview mode Query String */
fetchQueryStrData(qs = '') {
const qsParams = new URLSearchParams(qs);
const data = {
site: {
plans: {}
}
};
const allowedPlans = [];
let portalPrices;
let portalProducts = null;
let monthlyPrice, yearlyPrice, currency;
// Handle the query params key/value pairs
for (let pair of qsParams.entries()) {
const key = pair[0];
const value = decodeURIComponent(pair[1]);
if (key === 'button') {
data.site.portal_button = JSON.parse(value);
} else if (key === 'name') {
data.site.portal_name = JSON.parse(value);
} else if (key === 'isFree' && JSON.parse(value)) {
allowedPlans.push('free');
} else if (key === 'isMonthly' && JSON.parse(value)) {
allowedPlans.push('monthly');
} else if (key === 'isYearly' && JSON.parse(value)) {
allowedPlans.push('yearly');
} else if (key === 'portalPrices') {
portalPrices = value ? value.split(',') : [];
} else if (key === 'portalProducts') {
portalProducts = value ? value.split(',') : [];
} else if (key === 'page' && value) {
data.page = value;
} else if (key === 'accentColor' && (value === '' || value)) {
data.site.accent_color = value;
} else if (key === 'buttonIcon' && value) {
data.site.portal_button_icon = value;
} else if (key === 'signupButtonText') {
data.site.portal_button_signup_text = value || '';
} else if (key === 'buttonStyle' && value) {
data.site.portal_button_style = value;
} else if (key === 'monthlyPrice' && !isNaN(Number(value))) {
data.site.plans.monthly = Number(value);
monthlyPrice = Number(value);
} else if (key === 'yearlyPrice' && !isNaN(Number(value))) {
data.site.plans.yearly = Number(value);
yearlyPrice = Number(value);
} else if (key === 'currency' && value) {
const currencyValue = value.toUpperCase();
data.site.plans.currency = currencyValue;
data.site.plans.currency_symbol = getCurrencySymbol(currencyValue);
currency = currencyValue;
} else if (key === 'disableBackground') {
data.site.disableBackground = JSON.parse(value);
} else if (key === 'allowSelfSignup') {
data.site.allow_self_signup = JSON.parse(value);
} else if (key === 'membersSignupAccess' && value) {
data.site.members_signup_access = value;
}
}
data.site.portal_plans = allowedPlans;
data.site.portal_products = portalProducts;
if (portalPrices) {
data.site.portal_plans = portalPrices;
} else if (monthlyPrice && yearlyPrice && currency) {
data.site.prices = [
{
id: 'monthly',
stripe_price_id: 'dummy_stripe_monthly',
stripe_product_id: 'dummy_stripe_product',
active: 1,
nickname: 'Monthly',
currency: currency,
amount: monthlyPrice,
type: 'recurring',
interval: 'month'
},
{
id: 'yearly',
stripe_price_id: 'dummy_stripe_yearly',
stripe_product_id: 'dummy_stripe_product',
active: 1,
nickname: 'Yearly',
currency: currency,
amount: yearlyPrice,
type: 'recurring',
interval: 'year'
}
];
}
return data;
}
/**Fetch state data for billing notification */
fetchNotificationData() {
const {type, status, duration, autoHide, closeable} = NotificationParser({billingOnly: true}) || {};
if (['stripe:billing-update'].includes(type)) {
if (status === 'success') {
const popupNotification = createPopupNotification({
type, status, duration, closeable, autoHide, state: this.state,
message: status === 'success' ? 'Billing info updated successfully' : ''
});
return {
showPopup: true,
popupNotification
};
}
return {
showPopup: true
};
}
return {};
}
/** Fetch state from Portal Links */
fetchLinkData() {
const productMonthlyPriceQueryRegex = /^(?:(\w+?))?\/monthly$/;
const productYearlyPriceQueryRegex = /^(?:(\w+?))?\/monthly$/;
const offersRegex = /^offers\/(\w+?)\/?$/;
const [path] = window.location.hash.substr(1).split('?');
const linkRegex = /^\/portal\/?(?:\/(\w+(?:\/\w+)*))?\/?$/;
if (path && linkRegex.test(path)) {
const [,pagePath] = path.match(linkRegex);
const {page, pageQuery} = this.getPageFromLinkPath(pagePath) || {};
const lastPage = ['accountPlan', 'accountProfile'].includes(page) ? 'accountHome' : null;
const showPopup = (
['monthly', 'yearly'].includes(pageQuery) ||
productMonthlyPriceQueryRegex.test(pageQuery) ||
productYearlyPriceQueryRegex.test(pageQuery) ||
offersRegex.test(pageQuery)
) ? false : true;
return {
showPopup,
...(page ? {page} : {}),
...(pageQuery ? {pageQuery} : {}),
...(lastPage ? {lastPage} : {})
};
}
return {};
}
/** Fetch state from Preview mode */
fetchPreviewData() {
const [, qs] = window.location.hash.substr(1).split('?');
if (hasMode(['preview'])) {
const data = this.fetchQueryStrData(qs);
data.showPopup = true;
return data;
}
return {};
}
/* Get the accent color from data attributes */
getColorOverride() {
const scriptTag = document.querySelector('script[data-ghost]');
if (scriptTag && scriptTag.dataset.accentColor) {
return scriptTag.dataset.accentColor;
}
return false;
}
/** Fetch site and member session data with Ghost Apis */
async fetchApiData() {
const {siteUrl, customSiteUrl} = this.props;
try {
this.GhostApi = setupGhostApi({siteUrl});
const {site, member} = await this.GhostApi.init();
const colorOverride = this.getColorOverride();
if (colorOverride) {
site.accent_color = colorOverride;
}
this.setupFirstPromoter({site, member});
this.setupSentry({site});
return {site, member};
} catch (e) {
if (hasMode(['dev', 'test'], {customSiteUrl})) {
return {};
}
throw e;
}
}
/** Setup Sentry */
setupSentry({site}) {
const {portal_sentry: portalSentry, portal_version: portalVersion, version: ghostVersion} = site;
const appVersion = process.env.REACT_APP_VERSION || portalVersion;
const releaseTag = `portal@${appVersion}|ghost@${ghostVersion}`;
if (portalSentry && portalSentry.dsn) {
Sentry.init({
dsn: portalSentry.dsn,
environment: portalSentry.env || 'development',
release: releaseTag,
beforeSend: (event) => {
if (isSentryEventAllowed({event})) {
return event;
}
return null;
},
allowUrls: [
/https?:\/\/((www)\.)?unpkg\.com\/@tryghost\/portal/
]
});
}
}
/** Setup Firstpromoter script */
setupFirstPromoter({site, member}) {
const firstPromoterId = getFirstpromoterId({site});
const siteDomain = getSiteDomain({site});
if (firstPromoterId && siteDomain) {
const t = document.createElement('script');
t.type = 'text/javascript';
t.async = !0;
t.src = 'https://cdn.firstpromoter.com/fprom.js';
t.onload = t.onreadystatechange = function () {
let _t = this.readyState;
if (!_t || 'complete' === _t || 'loaded' === _t) {
try {
window.$FPROM.init(firstPromoterId, siteDomain);
if (member) {
const email = member.email;
const uid = member.uuid;
if (window.$FPROM) {
window.$FPROM.trackSignup({email: email, uid: uid});
} else {
const _fprom = window._fprom || [];
window._fprom = _fprom;
_fprom.push(['event', 'signup']);
_fprom.push(['email', email]);
_fprom.push(['uid', uid]);
}
}
} catch (err) {
// Log FP tracking failure
}
}
};
const e = document.getElementsByTagName('script')[0];
e.parentNode.insertBefore(t, e);
}
}
/** Handle actions from across App and update App state */
async dispatchAction(action, data) {
clearTimeout(this.timeoutId);
this.setState({
action: `${action}:running`
});
try {
const updatedState = await ActionHandler({action, data, state: this.state, api: this.GhostApi});
this.setState(updatedState);
/** Reset action state after short timeout if not failed*/
if (updatedState && updatedState.action && !updatedState.action.includes(':failed')) {
this.timeoutId = setTimeout(() => {
this.setState({
action: ''
});
}, 2000);
}
} catch (error) {
const popupNotification = createPopupNotification({
type: `${action}:failed`,
autoHide: true, closeable: true, status: 'error', state: this.state,
meta: {
error
}
});
this.setState({
action: `${action}:failed`,
popupNotification
});
}
}
/**Handle state update for preview url and Portal Link changes */
updateStateForPreviewLinks() {
const {site: previewSite, ...restPreviewData} = this.fetchPreviewData();
const {site: linkSite, ...restLinkData} = this.fetchLinkData();
const updatedState = {
site: {
...this.state.site,
...(linkSite || {}),
...(previewSite || {}),
plans: {
...(this.state.site && this.state.site.plans),
...(linkSite || {}).plans,
...(previewSite || {}).plans
}
},
...restLinkData,
...restPreviewData
};
this.handleSignupQuery({site: updatedState.site, pageQuery: updatedState.pageQuery});
this.setState(updatedState);
}
handleOfferQuery({site, offerId}) {
removePortalLinkFromUrl();
const prices = getAvailablePrices({site});
const priceId = prices?.[0]?.id;
if (this.state.member) {
this.dispatchAction('checkoutPlan', {plan: priceId, offerId});
} else {
this.dispatchAction('signup', {plan: priceId, offerId});
}
}
/** Handle direct signup link for a price */
handleSignupQuery({site, pageQuery}) {
const productMonthlyPriceQueryRegex = /^(?:(\w+?))?\/monthly$/;
const productYearlyPriceQueryRegex = /^(?:(\w+?))?\/monthly$/;
const offerQueryRegex = /^offers\/(\w+?)\/?$/;
let priceId = pageQuery;
if (offerQueryRegex.test(pageQuery || '')) {
const [, offerId] = pageQuery.match(offerQueryRegex);
this.handleOfferQuery({site, offerId});
return;
} else if (productMonthlyPriceQueryRegex.test(pageQuery || '')) {
const [, productId] = pageQuery.match(productMonthlyPriceQueryRegex);
const product = getProductFromId({site, productId});
priceId = product?.monthlyPrice?.id;
} else if (productYearlyPriceQueryRegex.test(pageQuery || '')) {
const [, productId] = pageQuery.match(productYearlyPriceQueryRegex);
const product = getProductFromId({site, productId});
priceId = product?.yearlyPrice?.id;
}
const queryPrice = getQueryPrice({site: site, priceId});
if (!this.state.member
&& pageQuery
&& pageQuery !== 'free'
) {
removePortalLinkFromUrl();
this.dispatchAction('signup', {plan: queryPrice?.id || priceId});
}
}
/**Get Portal page from Link/Data-attribute path*/
getPageFromLinkPath(path) {
const customPricesSignupRegex = /^signup\/?(?:\/(\w+?))?\/?$/;
const customMonthlyProductSignup = /^signup\/?(?:\/(\w+?))\/monthly\/?$/;
const customYearlyProductSignup = /^signup\/?(?:\/(\w+?))\/yearly\/?$/;
const customOfferRegex = /^offers\/(\w+?)\/?$/;
if (customOfferRegex.test(path)) {
return {
pageQuery: path
};
} else if (path === 'signup') {
return {
page: 'signup'
};
} else if (customMonthlyProductSignup.test(path)) {
const [, productId] = path.match(customMonthlyProductSignup);
return {
page: 'signup',
pageQuery: `${productId}/monthly`
};
} else if (customYearlyProductSignup.test(path)) {
const [, productId] = path.match(customYearlyProductSignup);
return {
page: 'signup',
pageQuery: `${productId}/yearly`
};
} else if (customPricesSignupRegex.test(path)) {
const [, pageQuery] = path.match(customPricesSignupRegex);
return {
page: 'signup',
pageQuery: pageQuery
};
} else if (path === 'signup/free') {
return {
page: 'signup',
pageQuery: 'free'
};
} else if (path === 'signup/monthly') {
return {
page: 'signup',
pageQuery: 'monthly'
};
} else if (path === 'signup/yearly') {
return {
page: 'signup',
pageQuery: 'yearly'
};
} else if (path === 'signin') {
return {
page: 'signin'
};
} else if (path === 'account') {
return {
page: 'accountHome'
};
} else if (path === 'account/plans') {
return {
page: 'accountPlan'
};
} else if (path === 'account/profile') {
return {
page: 'accountProfile'
};
}
return {};
}
/**Get Accent color from site data*/
getAccentColor() {
const {accent_color: accentColor} = this.state.site || {};
return accentColor;
}
/**Get final page set in App context from state data*/
getContextPage({site, page, member}) {
/**Set default page based on logged-in status */
if (!page) {
const loggedOutPage = isInviteOnlySite({site}) ? 'signin' : 'signup';
page = member ? 'accountHome' : loggedOutPage;
}
if (page === 'accountPlan' && isComplimentaryMember({member})) {
page = 'accountHome';
}
return getActivePage({page});
}
/**Get final member set in App context from state data*/
getContextMember({page, member, customSiteUrl}) {
if (hasMode(['dev', 'preview'], {customSiteUrl})) {
/** Use dummy member(free or paid) for account pages in dev/preview mode*/
if (isAccountPage({page})) {
if (hasMode(['dev'], {customSiteUrl})) {
return member || Fixtures.member.free;
} else if (hasMode(['preview'])) {
return Fixtures.member.preview;
} else {
return Fixtures.member.paid;
}
}
/** Ignore member for non-account pages in dev/preview mode*/
return null;
}
return member;
}
/**Get final App level context from App state*/
getContextFromState() {
const {site, member, action, page, lastPage, showPopup, pageQuery, popupNotification, customSiteUrl} = this.state;
const contextPage = this.getContextPage({site, page, member});
const contextMember = this.getContextMember({page: contextPage, member, customSiteUrl});
return {
site,
action,
brandColor: this.getAccentColor(),
page: contextPage,
pageQuery,
member: contextMember,
lastPage,
showPopup,
popupNotification,
customSiteUrl,
onAction: (_action, data) => this.dispatchAction(_action, data)
};
}
render() {
if (this.state.initStatus === 'success') {
return (
<SentryErrorBoundary site={this.state.site}>
<AppContext.Provider value={this.getContextFromState()}>
<PopupModal />
<TriggerButton />
<Notification />
</AppContext.Provider>
</SentryErrorBoundary>
);
}
return null;
}
}