Migrated code and history from Portal repo

refs https://github.com/TryGhost/Toolbox/issues/426

- this migrates all code and history over from the Portal repo so we can
  provide a better development experience
This commit is contained in:
Daniel Lockyer 2022-10-05 15:02:24 +07:00
commit 2f36651e3c
No known key found for this signature in database
119 changed files with 25936 additions and 0 deletions

View File

@ -0,0 +1,26 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.hbs]
insert_final_newline = false
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab

1
ghost/portal/.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_VERSION=$npm_package_version

View File

@ -0,0 +1 @@
REACT_APP_DEFAULT_PAGE=signup

View File

@ -0,0 +1 @@
node_modules

34
ghost/portal/.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Test
on:
pull_request:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: [ '16' ]
env:
FORCE_COLOR: 1
CI: true
name: Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: yarn install
- run: yarn build
- run: yarn test:ci
- uses: codecov/codecov-action@v3
- uses: daniellockyer/action-slack-build@master
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
status: ${{ job.status }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

80
ghost/portal/.gitignore vendored Normal file
View File

@ -0,0 +1,80 @@
# Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.*
# IDE
.idea/*
*.iml
*.sublime-*
.vscode/*
# OSX
.DS_Store
# Membersjs build folders
umd/
build/
# Allow .env file
!.env
## We use .env file to define NODE_PATH as recommended test-utils setup pattern to avoid relative imports.
# Refs: https://testing-library.com/docs/react-testing-library/setup#jest-and-create-react-app
# CRA also suggests `.env` files should be checked into source control
# Ref: https://create-react-app.dev/docs/adding-custom-environment-variables/#adding-development-environment-variables-in-env

21
ghost/portal/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

95
ghost/portal/README.md Normal file
View File

@ -0,0 +1,95 @@
# Portal
[![CI Status](https://github.com/TryGhost/portal/workflows/Test/badge.svg?branch=main)](https://github.com/TryGhost/portal/actions)
[![npm version](https://badge.fury.io/js/%40tryghost%2Fportal.svg)](https://badge.fury.io/js/%40tryghost%2Fportal)
[Drop-in script](https://ghost.org/help/setting-up-portal/) to make the bulk of Ghost membership features work on any theme.
## Usage
Ghost automatically injects Portal script on all sites running Ghost 4 or higher.
Alternatively, Portal can be enabled on non-ghost pages directly by inserting the below script on the page.
```html
<script defer src="https://unpkg.com/@tryghost/portal@latest/umd/portal.min.js" data-ghost="https://mymemberssite.com"></script>
```
The `data-ghost` attribute expects the URL for your Ghost site, which is the only input Portal needs to work with your site's membership data via Ghost APIs.
### Custom trigger button
By default, the script adds a default floating trigger button on the bottom right of your page which is used to trigger the popup on screen.
Its possible to add custom trigger button of your own by adding data attribute `data-portal` to any HTML tag on page, and also specify a specfic [page](https://github.com/TryGhost/Portal/blob/main/src/pages.js#L13-L22) to open from it by using it as `data-portal=signup`.
The script also adds custom class names to this element for open and close state of popup - `gh-portal-open` and `gh-portal-close`, allowing devs to update its UI based on popup state.
Refer the [docs](https://ghost.org/help/setup-members/#customize-portal-settings) to read about ways in which Portal can be customized for your site.
## Basic Setup
1. Clone this repository:
```shell
git@github.com:TryGhost/portal.git
```
2. Change into the new directory and install the dependencies:
```shell
cd portal
yarn
```
## For local development
This section is mostly relevant for core team only for active Portal development. Always use the unpkg link for testing/using latest released portal script.
- Run `yarn start:dev` to start Portal in development mode
- Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
- To use the local Portal script in a local Ghost site
- Update `config.local.json` in Ghost repo to add "portal" config pointing to local dev server url as instructed on terminal.
- By default, this uses port `5368` for loading local Portal script on Ghost site. It's also possible to specify a custom port when running the script using - `--port=xxxx`.
## Available Scripts
In the project directory, you can also run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn build`
Creates the production single minified bundle for external use in `umd/portal.min.js`. <br />
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Publish
Run `yarn ship` to publish new version of script.
`yarn ship` is an alias for `npm publish`
- Builds the script with latest code using `yarn build` (prePublish)
- Publishes package on npm as `@tryghost/portal` and creates an unpkg link for script at https://unpkg.com/@tryghost/portal@VERSION
(Core team only)
## Learn More
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

93
ghost/portal/package.json Normal file
View File

@ -0,0 +1,93 @@
{
"name": "@tryghost/portal",
"version": "2.13.1",
"license": "MIT",
"repository": {
"type": "git",
"url": "git://github.com/TryGhost/Portal.git"
},
"author": "Ghost Foundation",
"unpkg": "umd/portal.min.js",
"files": [
"umd/",
"LICENSE",
"README.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@sentry/react": "7.14.1",
"@sentry/tracing": "7.14.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "BROWSER=none react-scripts start",
"start:combined": "BROWSER=none node ./scripts/start-combined.js",
"start:dev": "node ./scripts/start-mode.js",
"dev": "node ./scripts/dev-mode.js",
"build": "npm run build:combined",
"build:original": "react-scripts build",
"build:combined": "node ./scripts/build-combined.js",
"build:bundle": "webpack --config webpack.config.js",
"test": "react-scripts test",
"test:ci": "yarn test --watchAll=false --coverage",
"eject": "react-scripts eject",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn publish && git push ${GHOST_UPSTREAM:-upstream} main --follow-tags; fi",
"posttest": "yarn lint",
"analyze": "source-map-explorer 'umd/*.js'",
"prepublishOnly": "yarn build"
},
"eslintConfig": {
"extends": [
"react-app",
"plugin:ghost/browser"
],
"plugins": [
"ghost"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"coverageReporters": [
"cobertura",
"text-summary",
"html"
]
},
"devDependencies": {
"chalk": "4.1.2",
"chokidar": "3.5.3",
"copy-webpack-plugin": "11.0.0",
"eslint-plugin-ghost": "2.15.1",
"minimist": "1.2.6",
"ora": "5.4.1",
"rewire": "6.0.0",
"serve-handler": "6.1.3",
"source-map-explorer": "2.5.3",
"webpack-cli": "4.10.0"
},
"resolutions": {
"//": "See https://github.com/facebook/create-react-app/issues/11773",
"react-error-overlay": "6.0.11"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,5 @@
{
"extends": [
"@tryghost:quietJS"
]
}

View File

@ -0,0 +1,29 @@
const rewire = require('rewire');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const defaults = rewire('react-scripts/scripts/build.js');
let config = defaults.__get__('config');
config.optimization.splitChunks = {
cacheGroups: {
default: false
}
};
config.optimization.runtimeChunk = false;
// JS: Save built file in `/umd`
config.output.filename = '../umd/portal.min.js';
// CSS: Remove MiniCssPlugin from list of plugins
config.plugins = config.plugins.filter(plugin => !(plugin instanceof MiniCssExtractPlugin));
// CSS: replaces all MiniCssExtractPlugin.loader with style-loader to embed CSS in JS
config.module.rules[1].oneOf = config.module.rules[1].oneOf.map((rule) => {
if (!Object.prototype.hasOwnProperty.call(rule, 'use')) {
return rule;
}
return Object.assign({}, rule, {
use: rule.use.map(options => (/mini-css-extract-plugin/.test(options.loader)
? {loader: require.resolve('style-loader'), options: {}}
: options))
});
});

View File

@ -0,0 +1,220 @@
const handler = require('serve-handler');
const http = require('http');
const chokidar = require('chokidar');
const chalk = require('chalk');
const {spawn} = require('child_process');
const minimist = require('minimist');
const ora = require('ora');
/* eslint-disable no-console */
const log = console.log;
/* eslint-enable no-console */
let buildProcess;
let fileChanges = [];
let spinner;
let stdOutChunks = [];
let stdErrChunks = [];
const {v, verbose, port = 5368, basic, b} = minimist(process.argv.slice(2));
const showVerbose = !!(v || verbose);
const showBasic = !!(b || basic);
function clearConsole({withHistory = true} = {}) {
if (!withHistory) {
process.stdout.write('\x1Bc');
return;
}
process.stdout.write(
process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'
);
}
function maybePluralize(count, noun, suffix = 's') {
return `${count} ${noun}${count !== 1 ? suffix : ''}`;
}
function printFileChanges() {
if (fileChanges.length > 0) {
const prefix = maybePluralize(fileChanges.length, 'file');
log(chalk.bold.hex('#ffa300').underline(`${prefix} changed`));
const message = fileChanges.map((path) => {
return chalk.hex('#ffa300').dim(`${path}`);
}).join('\n');
log(message);
log();
}
}
function printBuildSuccessDetails() {
if (showBasic) {
return;
}
if ((stdOutChunks && stdOutChunks.length > 0)) {
const detail = Buffer.concat(stdOutChunks.slice(4,7)).toString();
log();
log(chalk.dim(detail));
}
}
function printBuildErrorDetails() {
if ((stdOutChunks && stdOutChunks.length > 0)) {
const failDetails = Buffer.concat(stdOutChunks.slice(4, stdOutChunks.length - 1)).toString().replace(/^(?=\n)$|\s*$|\n\n+/gm, '');
log(chalk(failDetails));
}
if (stdErrChunks && stdErrChunks.length > 0) {
const stderrContent = Buffer.concat(stdErrChunks).toString();
log(chalk.dim(stderrContent));
}
}
function printBuildComplete(code) {
if (code === 0) {
if (!showVerbose) {
spinner && spinner.succeed(chalk.greenBright.bold('Build finished'));
printBuildSuccessDetails();
} else {
log();
log(chalk.bold.greenBright.bgBlackBright(`${'-'.repeat(25)}Build Success${'-'.repeat(25)}`));
}
} else {
if (!showVerbose) {
spinner && spinner.fail(chalk.redBright.bold('Build failed'));
printBuildErrorDetails();
} else {
log(chalk.bold.redBright.bgBlackBright(`${'-'.repeat(25)}Build finished: Failed${'-'.repeat(25)}`));
}
}
log();
}
function printConfigInstruction() {
const data = {
portal: {
url: `http://localhost:${port}/portal`
}
};
const stringifedData = JSON.stringify(data, null, 2);
const splitData = stringifedData.split('\n');
log();
splitData.forEach((_data, idx, arr) => {
if (idx === 0 || idx === arr.length - 1) {
log(chalk.grey(_data));
} else {
log(chalk.bold.whiteBright(_data));
}
});
log();
}
function printInstructions() {
log();
log(chalk.yellowBright.underline(`Add portal to your local Ghost config`));
printConfigInstruction();
log(chalk.cyanBright('='.repeat(50)));
log();
}
function printBuildStart() {
if (showVerbose) {
log(chalk.bold.greenBright.bgBlackBright(`${'-'.repeat(32)}Building${'-'.repeat(32)}`));
log();
} else {
spinner = ora(chalk.magentaBright.bold('Bundling files, hang on...')).start();
}
}
function onBuildComplete(code) {
buildProcess = null;
printBuildComplete(code);
stdErrChunks = [];
stdOutChunks = [];
if (fileChanges.length > 0) {
buildPortal();
} else {
log(chalk.yellowBright.bold.underline(`Watching file changes...\n`));
}
}
function getBuildOptions() {
process.env.FORCE_COLOR = 'true';
const options = {
shell: true,
env: process.env
};
if (showVerbose) {
options.stdio = 'inherit';
}
return options;
}
function buildPortal() {
if (buildProcess) {
return;
}
printFileChanges();
printBuildStart();
fileChanges = [];
const options = getBuildOptions();
buildProcess = spawn('yarn build', options);
buildProcess.on('close', onBuildComplete);
if (!showVerbose) {
buildProcess.stdout.on('data', (data) => {
stdOutChunks.push(data);
});
buildProcess.stderr.on('data', (data) => {
stdErrChunks.push(data);
});
}
}
function watchFiles() {
const watcher = chokidar.watch('.', {
ignored: /build|node_modules|.git|public|umd|scripts|(^|[\/\\])\../
});
watcher.on('ready', () => {
buildPortal();
}).on('change', (path) => {
if (!fileChanges.includes(path)) {
fileChanges.push(path);
}
if (!buildProcess) {
buildPortal();
}
});
}
function startDevServer() {
const server = http.createServer((request, response) => {
return handler(request, response, {
rewrites: [
{source: '/portal', destination: 'umd/portal.min.js'},
{source: '/portal.min.js.map', destination: 'umd/portal.min.js.map'}
],
headers: [
{
source: '**',
headers: [{
key: 'Cache-Control',
value: 'no-cache'
},{
key: 'Access-Control-Allow-Origin',
value: '*'
}]
}
]
});
});
server.listen(port, () => {
log(chalk.whiteBright(`Portal dev server is running on http://localhost:${port}`));
printInstructions();
watchFiles();
});
}
clearConsole({withHistory: false});
startDevServer();

View File

@ -0,0 +1,8 @@
/** Script to load Portal bundle for local development */
function loadScript(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
}
loadScript('http://localhost:3000/static/js/bundle.js');

View File

@ -0,0 +1,14 @@
const rewire = require('rewire');
const defaults = rewire('react-scripts/scripts/start.js');
let configFactory = defaults.__get__('configFactory');
defaults.__set__('configFactory', (env) => {
const config = configFactory(env);
config.optimization.splitChunks = {
cacheGroups: {
default: false
}
};
config.optimization.runtimeChunk = false;
return config;
});

View File

@ -0,0 +1,151 @@
const handler = require('serve-handler');
const http = require('http');
const chalk = require('chalk');
const {spawn} = require('child_process');
const minimist = require('minimist');
/* eslint-disable no-console */
const log = console.log;
/* eslint-enable no-console */
let yarnStartProcess;
let stdOutChunks = [];
let stdErrChunks = [];
let startYarnOutput = false;
const {v, verbose, port = 5368} = minimist(process.argv.slice(2));
const showVerbose = !!(v || verbose);
function clearConsole({withHistory = true} = {}) {
if (!withHistory) {
process.stdout.write('\x1Bc');
return;
}
process.stdout.write(
process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'
);
}
function printConfigInstruction() {
const data = {
portal: {
url: `http://localhost:${port}/portal`
}
};
const stringifedData = JSON.stringify(data, null, 2);
const splitData = stringifedData.split('\n');
log();
splitData.forEach((data, idx, arr) => {
if (idx === 0 || idx === arr.length - 1) {
log(chalk.grey(data));
} else {
log(chalk.bold.whiteBright(data));
}
});
log();
}
function printInstructions() {
log();
log(chalk.yellowBright.underline(`Add portal to your local Ghost config`));
printConfigInstruction();
log(chalk.cyanBright('='.repeat(50)));
log();
}
function onProcessClose(code) {
yarnStartProcess = null;
stdErrChunks = [];
stdOutChunks = [];
log(chalk.redBright.bold.underline(`Please restart the script...\n`));
}
function getBuildOptions() {
process.env.FORCE_COLOR = 'true';
const options = {
shell: true,
env: process.env
};
if (showVerbose) {
options.stdio = 'inherit';
}
return options;
}
function doYarnStart() {
if (yarnStartProcess) {
return;
}
const options = getBuildOptions();
yarnStartProcess = spawn('yarn start:combined', options);
['SIGINT', 'SIGTERM'].forEach(function (sig) {
yarnStartProcess.on(sig, function () {
yarnStartProcess && yarnStartProcess.exit();
});
});
yarnStartProcess.on('close', onProcessClose);
if (!showVerbose) {
yarnStartProcess.stdout.on('data', (data) => {
stdOutChunks.push(data);
printYarnProcessOutput(data);
});
yarnStartProcess.stderr.on('data', (data) => {
log(Buffer.from(data).toString());
stdErrChunks.push(data);
});
}
}
function printYarnProcessOutput(data) {
const dataStr = Buffer.from(data).toString();
const dataArr = dataStr.split('\n').filter((d) => {
return /\S/.test(d.trim());
});
if (dataArr.find(d => d.includes('Starting the development'))) {
startYarnOutput = true;
log(chalk.yellowBright('Starting the development server...\n'));
return;
}
dataArr.forEach((dataOut) => {
if (startYarnOutput) {
log(dataOut);
}
});
if (startYarnOutput) {
log();
}
}
function startDevServer() {
const server = http.createServer((request, response) => {
return handler(request, response, {
rewrites: [
{source: '/portal', destination: 'scripts/load-portal.js'}
],
headers: [
{
source: '**',
headers: [{
key: 'Cache-Control',
value: 'no-cache'
},{
key: 'Access-Control-Allow-Origin',
value: '*'
}]
}
]
});
});
server.listen(port, () => {
log(chalk.whiteBright(`Portal dev server is running on http://localhost:${port}`));
printInstructions();
doYarnStart();
});
}
clearConsole({withHistory: false});
startDevServer();

0
ghost/portal/src/App.css Normal file
View File

817
ghost/portal/src/App.js Normal file
View File

@ -0,0 +1,817 @@
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 * as Fixtures from './utils/fixtures';
import {getActivePage, isAccountPage, isOfferPage} from './pages';
import ActionHandler from './actions';
import './App.css';
import NotificationParser from './utils/notifications';
import {allowCompMemberUpgrade, createPopupNotification, getCurrencySymbol, getFirstpromoterId, getPriceIdFromPageQuery, getProductCadenceFromPrice, getProductFromId, getQueryPrice, getSiteDomain, isActiveOffer, isComplimentaryMember, isInviteOnlySite, isPaidMember, isSentryEventAllowed, removePortalLinkFromUrl} from './utils/helpers';
import {handleDataAttributes} from './data-attributes';
const React = require('react');
const DEV_MODE_DATA = {
showPopup: true,
site: Fixtures.site,
member: Fixtures.member.free,
page: 'accountEmail',
...Fixtures.paidMemberOnTier(),
pageData: Fixtures.offer
};
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);
this.setupCustomTriggerButton(props);
this.state = {
site: null,
member: null,
page: 'loading',
showPopup: false,
action: 'init:running',
initStatus: 'running',
lastPage: null,
customSiteUrl: props.customSiteUrl
};
}
componentDidMount() {
this.initSetup();
}
componentDidUpdate(prevProps, prevState) {
/**Handle custom trigger class change on popup open state change */
if (prevState.showPopup !== this.state.showPopup) {
this.handleCustomTriggerClassUpdate();
/** Remove background scroll when popup is opened */
try {
if (this.state.showPopup) {
/** When modal is opened, store current overflow and set as hidden */
this.bodyScroll = window.document?.body?.style?.overflow;
window.document.body.style.overflow = 'hidden';
} else {
/** When the modal is hidden, reset overflow property for body */
window.document.body.style.overflow = this.bodyScroll || '';
}
} catch (e) {
/** Ignore any errors for scroll handling */
}
}
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);
});
window.removeEventListener('hashchange', this.hashHandler, false);
}
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, pageData} = await this.fetchData();
const state = {
site,
member,
page,
lastPage,
pageQuery,
showPopup,
pageData,
popupNotification,
action: 'init:success',
initStatus: 'success'
};
this.handleSignupQuery({site, pageQuery, member});
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;
}
// Setup test mode data
if (hasMode(['test'])) {
return {
showPopup: this.props.showPopup !== undefined ? this.props.showPopup : true
};
}
return {};
}
/**Fetch state from Offer Preview mode query string*/
fetchOfferQueryStrData(qs = '') {
const qsParams = new URLSearchParams(qs);
const data = {};
// Handle the query params key/value pairs
for (let pair of qsParams.entries()) {
const key = pair[0];
const value = decodeURIComponent(pair[1]);
if (key === 'name') {
data.name = value || '';
} else if (key === 'code') {
data.code = value || '';
} else if (key === 'display_title') {
data.display_title = value || '';
} else if (key === 'display_description') {
data.display_description = value || '';
} else if (key === 'type') {
data.type = value || '';
} else if (key === 'cadence') {
data.cadence = value || '';
} else if (key === 'duration') {
data.duration = value || '';
} else if (key === 'duration_in_months' && !isNaN(Number(value))) {
data.duration_in_months = Number(value);
} else if (key === 'amount' && !isNaN(Number(value))) {
data.amount = Number(value);
} else if (key === 'currency') {
data.currency = value || '';
} else if (key === 'status') {
data.status = value || '';
} else if (key === 'tier_id') {
data.tier = {
id: value || Fixtures.offer.tier.id
};
}
}
return {
page: 'offer',
pageData: data
};
}
/** 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 qParams = new URLSearchParams(window.location.search);
if (qParams.get('uuid') && qParams.get('action') === 'unsubscribe') {
return {
showPopup: true,
page: 'unsubscribe',
pageData: {
uuid: qParams.get('uuid'),
newsletterUuid: qParams.get('newsletter'),
comments: qParams.get('comments')
}
};
}
const productMonthlyPriceQueryRegex = /^(?:(\w+?))?\/monthly$/;
const productYearlyPriceQueryRegex = /^(?:(\w+?))?\/yearly$/;
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'])) {
let data = {};
if (hasMode(['offerPreview'])) {
data = this.fetchOfferQueryStrData(qs);
} else {
data = this.fetchQueryStrData(qs);
}
return {
...data,
showPopup: true
};
}
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, apiUrl, apiKey} = this.props;
try {
this.GhostApi = this.props.api || setupGhostApi({siteUrl, apiUrl, apiKey});
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}) {
if (hasMode(['test'])) {
return null;
}
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}) {
if (hasMode(['test'])) {
return null;
}
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);
}
/** Handle Portal offer urls */
async handleOfferQuery({site, offerId, member = this.state.member}) {
const {portal_button: portalButton} = site;
removePortalLinkFromUrl();
if (!isPaidMember({member})) {
try {
const offerData = await this.GhostApi.site.offer({offerId});
const offer = offerData?.offers[0];
if (isActiveOffer({offer})) {
if (!portalButton) {
const product = getProductFromId({site, productId: offer.tier.id});
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
this.dispatchAction('openPopup', {
page: 'loading'
});
if (member) {
const {tierId, cadence} = getProductCadenceFromPrice({site, priceId: price.id});
this.dispatchAction('checkoutPlan', {plan: price.id, offerId, tierId, cadence});
} else {
const {tierId, cadence} = getProductCadenceFromPrice({site, priceId: price.id});
this.dispatchAction('signup', {plan: price.id, offerId, tierId, cadence});
}
} else {
this.dispatchAction('openPopup', {
page: 'offer',
pageData: offerData?.offers[0]
});
}
}
} catch (e) {
// ignore invalid portal url
}
}
}
/** Handle direct signup link for a price */
handleSignupQuery({site, pageQuery, member}) {
const offerQueryRegex = /^offers\/(\w+?)\/?$/;
let priceId = pageQuery;
if (offerQueryRegex.test(pageQuery || '')) {
const [, offerId] = pageQuery.match(offerQueryRegex);
this.handleOfferQuery({site, offerId, member});
return;
}
if (getPriceIdFromPageQuery({site, pageQuery})) {
priceId = getPriceIdFromPageQuery({site, pageQuery});
}
const queryPrice = getQueryPrice({site: site, priceId});
if (pageQuery
&& pageQuery !== 'free'
) {
removePortalLinkFromUrl();
const plan = queryPrice?.id || priceId;
if (plan !== 'free') {
this.dispatchAction('openPopup', {
page: 'loading'
});
}
const {tierId, cadence} = getProductCadenceFromPrice({site, priceId: plan});
this.dispatchAction('signup', {plan, tierId, cadence});
}
}
/**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'
};
} else if (path === 'account/newsletters') {
return {
page: 'accountEmail'
};
}
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}) && !allowCompMemberUpgrade({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}) || isOfferPage({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, pageData, 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,
pageData,
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;
}
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import {render} from '@testing-library/react';
import {site} from './utils/fixtures';
import App from './App';
const setup = async (overrides) => {
const testState = {
site,
member: null,
action: 'init:success',
brandColor: site.accent_color,
page: 'signup',
initStatus: 'success',
showPopup: true
};
const {...utils} = render(
<App testState={testState} />
);
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = await utils.findByTitle(/portal-popup/i);
return {
popupFrame,
triggerButtonFrame,
...utils
};
};
describe.skip('App', () => {
test('renders popup and trigger frames', async () => {
const {popupFrame, triggerButtonFrame} = await setup();
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
});
});

View File

@ -0,0 +1,16 @@
// Ref: https://reactjs.org/docs/context.html
const React = require('react');
const AppContext = React.createContext({
site: {},
member: {},
action: '',
lastPage: '',
brandColor: '',
pageData: {},
onAction: (action, data) => {
return {action, data};
}
});
export default AppContext;

478
ghost/portal/src/actions.js Normal file
View File

@ -0,0 +1,478 @@
import {createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl} from './utils/helpers';
function switchPage({data, state}) {
return {
page: data.page,
popupNotification: null,
lastPage: data.lastPage || null,
pageData: data.pageData || state.pageData
};
}
function togglePopup({state}) {
return {
showPopup: !state.showPopup
};
}
function openPopup({data}) {
return {
showPopup: true,
page: data.page,
...(data.pageQuery ? {pageQuery: data.pageQuery} : {}),
...(data.pageData ? {pageData: data.pageData} : {})
};
}
function back({state}) {
if (state.lastPage) {
return {
page: state.lastPage
};
} else {
return closePopup({state});
}
}
function closePopup({state}) {
removePortalLinkFromUrl();
return {
showPopup: false,
lastPage: null,
pageQuery: '',
popupNotification: null,
page: state.page === 'magiclink' ? '' : state.page
};
}
function openNotification({data}) {
return {
showNotification: true,
...data
};
}
function closeNotification({state}) {
return {
showNotification: false
};
}
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: false, closeable: true, state, status: 'error',
message: 'Failed to log out, please try again'
})
};
}
}
async function signin({data, api, state}) {
try {
await api.member.sendMagicLink(data);
return {
page: 'magiclink',
lastPage: 'signin'
};
} catch (e) {
return {
action: 'signin:failed',
popupNotification: createPopupNotification({
type: 'signin:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to log in, please try again'
})
};
}
}
async function signup({data, state, api}) {
try {
let {plan, tierId, cadence, email, name, newsletters, offerId} = data;
if (plan.toLowerCase() === 'free') {
await api.member.sendMagicLink(data);
} else {
if (tierId && cadence) {
await api.member.checkoutPlan({plan, tierId, cadence, email, name, newsletters, offerId});
} else {
({tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: plan}));
await api.member.checkoutPlan({plan, tierId, cadence, email, name, newsletters, offerId});
}
}
return {
page: 'magiclink',
lastPage: 'signup'
};
} catch (e) {
const message = e?.message || 'Failed to sign up, please try again';
return {
action: 'signup:failed',
popupNotification: createPopupNotification({
type: 'signup:failed', autoHide: false, closeable: true, state, status: 'error',
message
})
};
}
}
async function checkoutPlan({data, state, api}) {
try {
let {plan, offerId, tierId, cadence} = data;
if (!tierId || !cadence) {
({tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: plan}));
}
await api.member.checkoutPlan({
plan,
tierId,
cadence,
offerId,
metadata: {
checkoutType: 'upgrade'
}
});
} catch (e) {
return {
action: 'checkoutPlan:failed',
popupNotification: createPopupNotification({
type: 'checkoutPlan:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to process checkout, please try again'
})
};
}
}
async function updateSubscription({data, state, api}) {
try {
const {plan, planId, subscriptionId, cancelAtPeriodEnd} = data;
const {tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: planId});
await api.member.updateSubscription({
planName: plan,
tierId,
cadence,
subscriptionId,
cancelAtPeriodEnd,
planId: planId
});
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 updated successfully'
}),
page: 'accountHome',
member: member
};
} catch (e) {
return {
action: 'updateSubscription:failed',
popupNotification: createPopupNotification({
type: 'updateSubscription:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to update subscription, please try again'
})
};
}
}
async function cancelSubscription({data, state, api}) {
try {
const {subscriptionId, cancellationReason} = data;
await api.member.updateSubscription({
subscriptionId, smartCancel: true, cancellationReason
});
const member = await api.member.sessionData();
const action = 'cancelSubscription:success';
return {
action,
page: 'accountHome',
member: member
};
} catch (e) {
return {
action: 'cancelSubscription:failed',
popupNotification: createPopupNotification({
type: 'cancelSubscription:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to cancel subscription, please try again'
})
};
}
}
async function continueSubscription({data, state, api}) {
try {
const {subscriptionId} = data;
await api.member.updateSubscription({
subscriptionId, cancelAtPeriodEnd: false
});
const member = await api.member.sessionData();
const action = 'continueSubscription:success';
return {
action,
page: 'accountHome',
member: member
};
} catch (e) {
return {
action: 'continueSubscription:failed',
popupNotification: createPopupNotification({
type: 'continueSubscription:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to cancel subscription, please try again'
})
};
}
}
async function editBilling({data, state, api}) {
try {
await api.member.editBilling(data);
} catch (e) {
return {
action: 'editBilling:failed',
popupNotification: createPopupNotification({
type: 'editBilling:failed', autoHide: false, closeable: true, state, status: 'error',
message: 'Failed to update billing information, please try again'
})
};
}
}
async function clearPopupNotification() {
return {
popupNotification: null
};
}
async function showPopupNotification({data, state}) {
let {action, message = ''} = data;
action = action || 'showPopupNotification:success';
return {
popupNotification: createPopupNotification({
type: action,
autoHide: true,
closeable: true,
state,
status: 'success',
message
})
};
}
async function updateNewsletterPreference({data, state, api}) {
try {
const {newsletters, enableCommentNotifications} = data;
if (!newsletters && enableCommentNotifications === undefined) {
return {};
}
const updateData = {};
if (newsletters) {
updateData.newsletters = newsletters;
}
if (enableCommentNotifications !== undefined) {
updateData.enableCommentNotifications = enableCommentNotifications;
}
const member = await api.member.update(updateData);
const action = 'updateNewsletterPref:success';
return {
action,
member
};
} catch (e) {
return {
action: 'updateNewsletterPref:failed',
popupNotification: createPopupNotification({
type: 'updateNewsletter:failed',
autoHide: true, closeable: true, state, status: 'error',
message: 'Failed to update newsletter settings'
})
};
}
}
async function updateNewsletter({data, state, api}) {
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, status: 'success',
message: 'Email 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.updateEmailAddress({email});
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 refreshMemberData({state, api}) {
if (state.member) {
try {
const member = await api.member.sessionData();
if (member) {
return {
member,
success: true
};
}
return null;
} catch (err) {
return {
success: false,
error: err
};
}
}
return null;
}
async function updateProfile({data, state, api}) {
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: 'updateProfile:failed',
...(dataUpdate.success ? {member: dataUpdate.member} : {}),
popupNotification: createPopupNotification({
type: 'updateProfile:failed', autoHide: true, closeable: true, status: 'error', message, state
})
};
} 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 details' : 'Account details updated successfully';
return {
action,
...(dataUpdate.success ? {member: dataUpdate.member} : {}),
...(dataUpdate.success ? {page: 'accountHome'} : {}),
popupNotification: createPopupNotification({
type: action, autoHide: dataUpdate.success, 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,
...(emailUpdate.success ? {page: 'accountHome'} : {}),
popupNotification: createPopupNotification({
type: action, autoHide: emailUpdate.success, 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 details updated successfully'
})
};
}
const Actions = {
togglePopup,
openPopup,
closePopup,
switchPage,
openNotification,
closeNotification,
back,
signout,
signin,
signup,
updateSubscription,
cancelSubscription,
continueSubscription,
updateNewsletter,
updateProfile,
refreshMemberData,
clearPopupNotification,
editBilling,
checkoutPlan,
updateNewsletterPreference,
showPopupNotification
};
/** Handle actions in the App, returns updated state */
export default async function ActionHandler({action, data, state, api}) {
const handler = Actions[action];
if (handler) {
return await handler({data, state, api}) || {};
}
return {};
}

View File

@ -0,0 +1,21 @@
import setupGhostApi from './utils/api';
function sendEntryViewEvent({analyticsId, api}) {
if (analyticsId) {
api.analytics.pushEvent({
type: 'entry_view',
entry_id: analyticsId,
entry_url: window.location.href,
created_at: new Date()
});
}
}
function setupAnalytics({siteUrl, analyticsId}) {
const GhostApi = setupGhostApi({siteUrl});
// Fire page/post view event
sendEntryViewEvent({analyticsId, api: GhostApi});
return {};
}
export default setupAnalytics;

View File

@ -0,0 +1,35 @@
import React, {Component} from 'react';
import {createPortal} from 'react-dom';
export default class Frame extends Component {
componentDidMount() {
this.node.addEventListener('load', this.handleLoad);
}
handleLoad = () => {
this.setupFrameBaseStyle();
};
componentWillUnmout() {
this.node.removeEventListener('load', this.handleLoad);
}
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();
}
}
render() {
const {children, head, title = '', style = {}, ...rest} = this.props;
return (
<iframe srcDoc={`<!DOCTYPE html>`} {...rest} ref={node => (this.node = node)} title={title} style={style} frameBorder="0">
{this.iframeHead && createPortal(head, this.iframeHead)}
{this.iframeRoot && createPortal(children, this.iframeRoot)}
</iframe>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,145 @@
export const GlobalStyles = `
/* Colors
/* ----------------------------------------------------- */
:root {
--black: #000;
--grey0: #1d1d1d;
--grey1: #333;
--grey2: #3d3d3d;
--grey3: #474747;
--grey4: #515151;
--grey5: #686868;
--grey6: #7f7f7f;
--grey7: #979797;
--grey8: #aeaeae;
--grey9: #c5c5c5;
--grey10: #dcdcdc;
--grey11: #e1e1e1;
--grey12: #eaeaea;
--grey13: #f9f9f9;
--grey14: #fbfbfb;
--white: #fff;
--red: #f02525;
--yellow: #FFDC15;
--green: #7FC724;
}
/* Globals
/* ----------------------------------------------------- */
html {
font-size: 62.5%;
height: 100%;
}
body {
margin: 0px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: 1.6rem;
height: 100%;
line-height: 1.6em;
font-weight: 400;
font-style: normal;
color: var(--grey2);
box-sizing: border-box;
overflow: hidden;
}
button,
button span {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
*, ::after, ::before {
box-sizing: border-box;
}
h1, h2, h3, h4, h5, h6, p {
line-height: 1.15em;
padding: 0;
margin: 0;
}
h1 {
font-size: 35px;
font-weight: 700;
letter-spacing: -0.022em;
}
h2 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.021em;
}
h3 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.019em;
}
p {
font-size: 15px;
line-height: 1.5em;
margin-bottom: 24px;
}
strong {
font-weight: 600;
}
a,
.gh-portal-link {
cursor: pointer;
}
svg {
box-sizing: content-box;
}
input,
textarea {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: 1.5rem;
}
textarea {
padding: 10px;
line-height: 1.5em;
}
@media (max-width: 1440px) {
h1 {
font-size: 32px;
letter-spacing: -0.022em;
}
h2 {
font-size: 28px;
letter-spacing: -0.021em;
}
h3 {
font-size: 26px;
letter-spacing: -0.02em;
}
}
@media (max-width: 480px) {
h1 {
font-size: 30px;
letter-spacing: -0.021em;
}
h2 {
font-size: 26px;
letter-spacing: -0.02em;
}
h3 {
font-size: 24px;
letter-spacing: -0.019em;
}
}
`;
export default GlobalStyles;

View File

@ -0,0 +1,243 @@
import Frame from './Frame';
import AppContext from '../AppContext';
import NotificationStyle from './Notification.styles';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
import {ReactComponent as CheckmarkIcon} from '../images/icons/checkmark-fill.svg';
import {ReactComponent as WarningIcon} from '../images/icons/warning-fill.svg';
import NotificationParser, {clearURLParams} from '../utils/notifications';
import {getPortalLink} from '../utils/helpers';
const React = require('react');
const Styles = () => {
return {
frame: {
zIndex: '4000000',
position: 'fixed',
top: '0',
right: '0',
maxWidth: '415px',
width: '100%',
height: '120px',
animation: '250ms ease 0s 1 normal none running animation-bhegco',
transition: 'opacity 0.3s ease 0s',
overflow: 'hidden'
}
};
};
const NotificationText = ({type, status, context}) => {
const signinPortalLink = getPortalLink({page: 'signin', siteUrl: context.site.url});
const singupPortalLink = getPortalLink({page: 'signup', siteUrl: context.site.url});
if (type === 'signin' && status === 'success' && context.member) {
const firstname = context.member.firstname || '';
return (
<p>
Welcome back{(firstname ? ', ' + firstname : '')}!<br />You've successfully signed in.
</p>
);
} else if (type === 'signin' && status === 'error') {
return (
<p>
Could not sign in. Login link expired. <a href={signinPortalLink} target="_parent">Click here to retry</a>
</p>
);
} else if (type === 'signup' && status === 'success') {
return (
<p>
You've successfully subscribed to <br /><strong>{context.site.title}</strong>
</p>
);
} else if (type === 'signup-paid' && status === 'success') {
return (
<p>
You've successfully subscribed to <br /><strong>{context.site.title}</strong>
</p>
);
} else if (type === 'updateEmail' && status === 'success') {
return (
<p>
Success! Your email is updated.
</p>
);
} else if (type === 'updateEmail' && status === 'error') {
return (
<p>
Could not update email! Invalid link.
</p>
);
} else if (type === 'signup' && status === 'error') {
return (
<p>
Signup error: Invalid link <br /><a href={singupPortalLink} target="_parent">Click here to retry</a>
</p>
);
} else if (type === 'signup-paid' && status === 'error') {
return (
<p>
Signup error: Invalid link <br /><a href={singupPortalLink} target="_parent">Click here to retry</a>
</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! Check your email for magic link to sign-in.
</p>
);
} else if (type === 'stripe:checkout' && status === 'warning') {
// Stripe checkout flow was cancelled
if (context.member) {
return (
<p>
Plan upgrade was cancelled.
</p>
);
}
return (
<p>
Plan checkout was cancelled.
</p>
);
}
return (
<p>
{status === 'success' ? 'Success' : 'Error'}
</p>
);
};
class NotificationContent extends React.Component {
static contextType = AppContext;
constructor() {
super();
this.state = {
className: ''
};
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
onNotificationClose() {
this.props.onHideNotification();
}
componentDidUpdate() {
const {showPopup} = this.context;
if (!this.state.className && showPopup) {
this.setState({
className: 'slideout'
});
}
}
componentDidMount() {
const {autoHide, duration = 2400} = this.props;
const {showPopup} = this.context;
if (showPopup) {
this.setState({
className: 'slideout'
});
} else if (autoHide) {
this.timeoutId = setTimeout(() => {
this.setState({
className: 'slideout'
});
}, duration);
}
}
onAnimationEnd(e) {
if (e.animationName === 'notification-slideout' || e.animationName === 'notification-slideout-mobile') {
this.props.onHideNotification(e);
}
}
render() {
const {type, status} = this.props;
const {className = ''} = this.state;
const statusClass = status ? ` ${status}` : ' neutral';
const slideClass = className ? ` ${className}` : '';
return (
<div className='gh-portal-notification-wrapper'>
<div className={`gh-portal-notification${statusClass}${slideClass}`} onAnimationEnd={e => this.onAnimationEnd(e)}>
{(status === 'error' ? <WarningIcon className='gh-portal-notification-icon error' alt=''/> : <CheckmarkIcon className='gh-portal-notification-icon success' alt=''/>)}
<NotificationText type={type} status={status} context={this.context} />
<CloseIcon className='gh-portal-notification-closeicon' alt='Close' onClick={e => this.onNotificationClose(e)} />
</div>
</div>
);
}
}
export default class Notification extends React.Component {
static contextType = AppContext;
constructor() {
super();
const {type, status, autoHide, duration} = NotificationParser() || {};
this.state = {
active: true,
type,
status,
autoHide,
duration,
className: ''
};
}
onHideNotification() {
const type = this.state.type;
const deleteParams = [];
if (['signin', 'signup'].includes(type)) {
deleteParams.push('action', 'success');
} else if (['stripe:checkout'].includes(type)) {
deleteParams.push('stripe');
}
clearURLParams(deleteParams);
this.context.onAction('refreshMemberData');
this.setState({
active: false
});
}
renderFrameStyles() {
const styles = `
:root {
--brandcolor: ${this.context.brandColor}
}
` + NotificationStyle;
return (
<style dangerouslySetInnerHTML={{__html: styles}} />
);
}
render() {
const Style = Styles({brandColor: this.context.brandColor});
const frameStyle = {
...Style.frame
};
if (!this.state.active) {
return null;
}
const {type, status, autoHide, duration} = this.state;
if (type && status) {
return (
<Frame style={frameStyle} title="portal-notification" head={this.renderFrameStyles()} className='gh-portal-notification-iframe' >
<NotificationContent {...{type, status, autoHide, duration}} onHideNotification={e => this.onHideNotification(e)} />
</Frame>
);
}
return null;
}
}

View File

@ -0,0 +1,140 @@
import {GlobalStyles} from './Global.styles';
const NotificationStyles = `
.gh-portal-notification-wrapper {
position: relative;
overflow: hidden;
height: 100%;
width: 100%;
}
.gh-portal-notification {
position: absolute;
display: flex;
align-items: center;
top: 12px;
right: 12px;
width: 100%;
padding: 14px 44px 18px 20px;
max-width: 380px;
min-height: 66px;
font-size: 1.3rem;
letter-spacing: 0.2px;
background: rgba(33,33,33,0.95);
backdrop-filter: blur(8px);
color: var(--white);
border-radius: 7px;
box-shadow: 0 3.2px 3.6px rgba(0, 0, 0, 0.024), 0 8.8px 10px -5px rgba(0, 0, 0, 0.08);
animation: notification-slidein 0.55s cubic-bezier(0.215, 0.610, 0.355, 1.000);
}
.gh-portal-notification.slideout {
animation: notification-slideout 0.4s cubic-bezier(0.550, 0.055, 0.675, 0.190);
}
.gh-portal-notification.hide {
display: none;
}
.gh-portal-notification p {
flex-grow: 1;
font-size: 1.4rem;
line-height: 1.5em;
text-align: left;
margin: 0;
padding: 0 0 0 40px;
color: var(--grey13);
}
.gh-portal-notification p strong {
color: var(--white);
}
.gh-portal-notification a {
color: var(--white);
text-decoration: underline;
transition: all 0.2s ease-in-out;
outline: none;
}
.gh-portal-notification a:hover {
opacity: 0.8;
}
.gh-portal-notification-icon {
position: absolute;
top: calc(50% - 14px);
left: 17px;
width: 28px;
height: 28px;
}
.gh-portal-notification-icon.success {
color: var(--green);
}
.gh-portal-notification-icon.error {
color: #FF2828;
}
.gh-portal-notification-closeicon {
position: absolute;
top: 5px;
bottom: 0;
right: 5px;
color: var(--white);
cursor: pointer;
width: 12px;
height: 12px;
padding: 10px;
transition: all 0.2s ease-in-out forwards;
opacity: 0.8;
}
.gh-portal-notification-closeicon:hover {
opacity: 1.0;
}
@keyframes notification-slidein {
0% { transform: translateX(380px); }
60% { transform: translateX(-6px); }
100% { transform: translateX(0); }
}
@keyframes notification-slideout {
0% { transform: translateX(0); }
30% { transform: translateX(-10px); }
100% { transform: translateX(380px); }
}
@keyframes notification-slidein-mobile {
0% { transform: translateY(-150px); }
50% { transform: translateY(6px); }
100% { transform: translateY(0); }
}
@keyframes notification-slideout-mobile {
0% { transform: translateY(0); }
35% { transform: translateY(6px); }
100% { transform: translateY(-150px); }
}
@media (max-width: 414px) {
.gh-portal-notification {
left: 12px;
max-width: calc(100% - 24px);
animation-name: notification-slidein-mobile;
}
.gh-portal-notification.slideout {
animation-duration: 0.55s;
animation-name: notification-slideout-mobile;
}
}
`;
const NotificationStyle =
GlobalStyles +
NotificationStyles;
export default NotificationStyle;

View File

@ -0,0 +1,308 @@
import Frame from './Frame';
import {hasMode} from '../utils/check-mode';
import AppContext from '../AppContext';
import {getFrameStyles} from './Frame.styles';
import Pages, {getActivePage} from '../pages';
import PopupNotification from './common/PopupNotification';
import PoweredBy from './common/PoweredBy';
import {getSiteProducts, isInviteOnlySite, isCookiesDisabled, hasFreeProductPrice} from '../utils/helpers';
const React = require('react');
const StylesWrapper = ({member}) => {
return {
modalContainer: {
zIndex: '3999999',
position: 'fixed',
left: '0',
top: '0',
width: '100%',
height: '100%',
overflow: 'hidden'
},
frame: {
common: {
margin: 'auto',
position: 'relative',
padding: '0',
outline: '0',
width: '100%',
opacity: '1',
overflow: 'hidden',
height: '100%'
}
},
page: {
links: {
width: '600px'
}
}
};
};
function CookieDisabledBanner({message}) {
const cookieDisabled = isCookiesDisabled();
if (cookieDisabled) {
return (
<div className='gh-portal-cookiebanner'>{message}</div>
);
}
return null;
}
class PopupContent extends React.Component {
static contextType = AppContext;
componentDidMount() {
// Handle Esc to close popup
if (this.node && !hasMode(['preview'])) {
this.node.focus();
this.keyUphandler = (event) => {
if (event.key === 'Escape') {
this.dismissPopup(event);
}
};
this.node.ownerDocument.removeEventListener('keyup', this.keyUphandler);
this.node.ownerDocument.addEventListener('keyup', this.keyUphandler);
}
this.sendContainerHeightChangeEvent();
}
dismissPopup(event) {
const eventTargetTag = (event.target && event.target.tagName);
// If focused on input field, only allow close if no value entered
const allowClose = eventTargetTag !== 'INPUT' || (eventTargetTag === 'INPUT' && !event?.target?.value);
if (allowClose) {
this.context.onAction('closePopup');
}
}
sendContainerHeightChangeEvent() {
if (this.node && hasMode(['preview'])) {
if (this.node?.clientHeight !== this.lastContainerHeight) {
this.lastContainerHeight = this.node?.clientHeight;
window.document.body.style.overflow = 'hidden';
window.document.body.style['scrollbar-width'] = 'none';
window.parent.postMessage({
type: 'portal-preview-updated',
payload: {
height: this.lastContainerHeight
}
}, '*');
}
}
}
componentDidUpdate() {
this.sendContainerHeightChangeEvent();
}
componentWillUnmount() {
if (this.node) {
this.node.ownerDocument.removeEventListener('keyup', this.keyUphandler);
}
}
handlePopupClose(e) {
if (hasMode(['preview'])) {
return;
}
e.preventDefault();
if (e.target === e.currentTarget) {
this.context.onAction('closePopup');
}
}
renderActivePage() {
const {page} = this.context;
getActivePage({page});
const PageComponent = Pages[page];
return (
<PageComponent />
);
}
renderPopupNotification() {
const {popupNotification} = this.context;
if (!popupNotification || !popupNotification.type) {
return null;
}
return (
<PopupNotification />
);
}
render() {
const {page, pageQuery, site, customSiteUrl} = this.context;
const products = getSiteProducts({site});
const noOfProducts = products.length;
getActivePage({page});
const Styles = StylesWrapper({page});
const pageStyle = {
...Styles.page[page]
};
let popupWidthStyle = '';
let popupSize = 'regular';
let cookieBannerText = '';
let pageClass = page;
switch (page) {
case 'signup':
cookieBannerText = 'Cookies must be enabled in your browser to sign up.';
break;
case 'signin':
cookieBannerText = 'Cookies must be enabled in your browser to sign in.';
break;
case 'accountHome':
pageClass = 'account-home';
break;
case 'accountProfile':
pageClass = 'account-profile';
break;
case 'accountPlan':
pageClass = 'account-plan';
break;
default:
cookieBannerText = 'Cookies must be enabled in your browser.';
pageClass = page;
break;
}
if (noOfProducts > 1 && !isInviteOnlySite({site, pageQuery})) {
if (page === 'signup') {
pageClass += ' full-size';
popupSize = 'full';
}
}
const freeProduct = hasFreeProductPrice({site});
if ((freeProduct && noOfProducts > 2) || (!freeProduct && noOfProducts > 1)) {
if (page === 'accountPlan') {
pageClass += ' full-size';
popupSize = 'full';
}
}
let className = 'gh-portal-popup-container';
if (hasMode(['preview'])) {
pageClass += ' preview';
}
if (hasMode(['preview'], {customSiteUrl}) && !site.disableBackground) {
className += ' preview';
}
if (hasMode(['dev'])) {
className += ' dev';
}
const containerClassName = `${className} ${popupWidthStyle} ${pageClass}`;
return (
<>
<div className={'gh-portal-popup-wrapper ' + pageClass} onClick={e => this.handlePopupClose(e)}>
<div className={containerClassName} style={pageStyle} ref={node => (this.node = node)} tabIndex={-1}>
<CookieDisabledBanner message={cookieBannerText} />
{this.renderPopupNotification()}
{this.renderActivePage()}
{(popupSize === 'full' ?
<div className={'gh-portal-powered inside ' + (hasMode(['preview']) ? 'hidden ' : '') + pageClass}>
<PoweredBy />
</div>
: '')}
</div>
</div>
<div className={'gh-portal-powered outside ' + (hasMode(['preview']) ? 'hidden ' : '') + pageClass}>
<PoweredBy />
</div>
</>
);
}
}
export default class PopupModal extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {
height: null
};
}
renderCurrentPage(page) {
const PageComponent = Pages[page];
return (
<PageComponent />
);
}
onHeightChange(height) {
this.setState({height});
}
handlePopupClose(e) {
e.preventDefault();
if (e.target === e.currentTarget) {
this.context.onAction('closePopup');
}
}
renderFrameStyles() {
const {site} = this.context;
const FrameStyle = getFrameStyles({site});
const styles = `
:root {
--brandcolor: ${this.context.brandColor}
}
` + FrameStyle;
return (
<>
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</>
);
}
renderFrameContainer() {
const {member, site, customSiteUrl} = this.context;
const Styles = StylesWrapper({member});
const frameStyle = {
...Styles.frame.common
};
let className = 'gh-portal-popup-background';
if (hasMode(['preview'])) {
Styles.modalContainer.zIndex = '3999997';
}
if (hasMode(['preview'], {customSiteUrl}) && !site.disableBackground) {
className += ' preview';
}
if (hasMode(['dev'])) {
className += ' dev';
}
return (
<div style={Styles.modalContainer}>
<Frame style={frameStyle} title="portal-popup" head={this.renderFrameStyles()}>
<div className={className} onClick = {e => this.handlePopupClose(e)}></div>
<PopupContent />
</Frame>
</div>
);
}
render() {
const {showPopup} = this.context;
if (showPopup) {
return this.renderFrameContainer();
}
return null;
}
}

View File

@ -0,0 +1,260 @@
import Frame from './Frame';
import MemberGravatar from './common/MemberGravatar';
import AppContext from '../AppContext';
import {ReactComponent as UserIcon} from '../images/icons/user.svg';
import {ReactComponent as ButtonIcon1} from '../images/icons/button-icon-1.svg';
import {ReactComponent as ButtonIcon2} from '../images/icons/button-icon-2.svg';
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 {hasMode} from '../utils/check-mode';
const React = require('react');
const ICON_MAPPING = {
'icon-1': ButtonIcon1,
'icon-2': ButtonIcon2,
'icon-3': ButtonIcon3,
'icon-4': ButtonIcon4,
'icon-5': ButtonIcon5
};
const Styles = ({brandColor, hasText}) => {
const frame = {
...(!hasText ? {width: '105px'} : {}),
...(hasMode(['preview']) ? {opacity: 1} : {})
};
return {
frame: {
zIndex: '3999998',
position: 'fixed',
bottom: '0',
right: '0',
width: '500px',
maxWidth: '500px',
height: '98px',
animation: '250ms ease 0s 1 normal none running animation-bhegco',
transition: 'opacity 0.3s ease 0s',
overflow: 'hidden',
...frame
},
userIcon: {
width: '34px',
height: '34px',
color: '#fff'
},
buttonIcon: {
width: '24px',
height: '24px',
color: '#fff'
},
closeIcon: {
width: '20px',
height: '20px',
color: '#fff'
}
};
};
class TriggerButtonContent extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = { };
this.container = React.createRef();
this.height = null;
this.width = null;
}
updateHeight(height) {
this.props.updateHeight && this.props.updateHeight(height);
}
updateWidth(width) {
this.props.updateWidth && this.props.updateWidth(width);
}
componentDidMount() {
if (this.container) {
this.height = this.container.current && this.container.current.offsetHeight;
this.width = this.container.current && this.container.current.offsetWidth;
this.updateHeight(this.height);
this.updateWidth(this.width);
}
}
componentDidUpdate() {
if (this.container) {
const height = this.container.current && this.container.current.offsetHeight;
let width = this.container.current && this.container.current.offsetWidth;
if (height !== this.height) {
this.height = height;
this.updateHeight(this.height);
}
if (width !== this.width) {
this.width = width;
this.updateWidth(this.width);
}
}
}
renderTriggerIcon() {
const {portal_button_icon: buttonIcon = '', portal_button_style: buttonStyle = ''} = this.context.site || {};
const Style = Styles({brandColor: this.context.brandColor});
const memberGravatar = this.context.member && this.context.member.avatar_image;
if (!buttonStyle.includes('icon') && !this.context.member) {
return null;
}
if (memberGravatar) {
return (
<MemberGravatar gravatar={memberGravatar} />
);
}
if (this.context.member) {
return (
<UserIcon style={Style.userIcon} />
);
} else {
if (Object.keys(ICON_MAPPING).includes(buttonIcon)) {
const ButtonIcon = ICON_MAPPING[buttonIcon];
return (
<ButtonIcon style={Style.buttonIcon} />
);
} else if (buttonIcon) {
return (
<img style={{width: '26px', height: '26px'}} src={buttonIcon} alt="" />
);
} else {
if (this.hasText()) {
Style.userIcon.width = '26px';
Style.userIcon.height = '26px';
}
return (
<UserIcon style={Style.userIcon} />
);
}
}
}
hasText() {
const {
portal_button_signup_text: buttonText,
portal_button_style: buttonStyle
} = this.context.site;
return ['icon-and-text', 'text-only'].includes(buttonStyle) && !this.context.member && buttonText;
}
renderText() {
const {
portal_button_signup_text: buttonText
} = this.context.site;
if (this.hasText()) {
return (
<span className='gh-portal-triggerbtn-label'> {buttonText} </span>
);
}
return null;
}
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;
this.context.onAction('openPopup', {page});
}
}
render() {
const hasText = this.hasText();
const {member} = this.context;
const triggerBtnClass = member ? 'halo' : '';
if (hasText) {
return (
<div className='gh-portal-triggerbtn-wrapper' ref={this.container}>
<div className='gh-portal-triggerbtn-container with-label' onClick={e => this.onToggle(e)}>
{this.renderTriggerIcon()}
{(hasText ? this.renderText() : '')}
</div>
</div>
);
}
return (
<div className='gh-portal-triggerbtn-wrapper'>
<div className={'gh-portal-triggerbtn-container ' + triggerBtnClass} onClick={e => this.onToggle(e)}>
{this.renderTriggerIcon()}
</div>
</div>
);
}
}
export default class TriggerButton extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {
width: null
};
}
onWidthChange(width) {
this.setState({width});
}
hasText() {
const {
portal_button_signup_text: buttonText,
portal_button_style: buttonStyle
} = this.context.site;
return ['icon-and-text', 'text-only'].includes(buttonStyle) && !this.context.member && buttonText;
}
renderFrameStyles() {
const styles = `
:root {
--brandcolor: ${this.context.brandColor}
}
` + TriggerButtonStyle;
return (
<style dangerouslySetInnerHTML={{__html: styles}} />
);
}
render() {
const {portal_button: portalButton} = this.context.site;
const {showPopup} = this.context;
if (!portalButton || hasMode(['offerPreview'])) {
return null;
}
const hasText = this.hasText();
const Style = Styles({brandColor: this.context.brandColor, hasText});
const frameStyle = {
...Style.frame
};
if (this.state.width) {
const updatedWidth = this.state.width + 2;
frameStyle.width = `${updatedWidth}px`;
}
return (
<Frame className='gh-portal-triggerbtn-iframe' style={frameStyle} title="portal-trigger" head={this.renderFrameStyles()}>
<TriggerButtonContent isPopupOpen={showPopup} updateWidth={width => this.onWidthChange(width)} />
</Frame>
);
}
}

View File

@ -0,0 +1,85 @@
import {GlobalStyles} from './Global.styles';
import {AvatarStyles} from './common/MemberGravatar';
const TriggerButtonStyles = `
.gh-portal-triggerbtn-wrapper {
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
height: 100%;
opacity: 1;
transition: transform 0.16s linear 0s; opacity 0.08s linear 0s;
user-select: none;
line-height: 1;
padding: 10px 28px 0 17px;
}
.gh-portal-triggerbtn-wrapper span {
margin-bottom: 1px;
}
.gh-portal-triggerbtn-container {
position: relative;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: var(--brandcolor);
height: 60px;
min-width: 60px;
box-shadow: rgba(0, 0, 0, 0.24) 0px 8px 16px -2px;
border-radius: 999px;
transition: opacity 0.3s ease;
}
.gh-portal-triggerbtn-container:before {
position: absolute;
content: "";
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0);
transition: background 0.3s ease;
}
.gh-portal-triggerbtn-container:hover:before {
background: rgba(255, 255, 255, 0.08);
}
.gh-portal-triggerbtn-container.halo:before {
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
border: 4px solid rgba(255, 255, 255, 0.15);
}
.gh-portal-triggerbtn-container.with-label {
padding: 0 12px 0 16px;
}
.gh-portal-triggerbtn-label {
padding: 8px;
color: rgb(255, 255, 255);
display: block;
white-space: nowrap;
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
}
.gh-portal-avatar {
margin-bottom: 0px !important;
width: 60px;
height: 60px;
}
`;
const TriggerButtonStyle =
GlobalStyles +
TriggerButtonStyles +
AvatarStyles;
export default TriggerButtonStyle;

View File

@ -0,0 +1,23 @@
import React from 'react';
import {render} from '../utils/test-utils';
import TriggerButton from './TriggerButton';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<TriggerButton />
);
const triggerFrame = utils.getByTitle('portal-trigger');
return {
triggerFrame,
...utils
};
};
describe('Trigger Button', () => {
test('renders', () => {
const {triggerFrame} = setup();
expect(triggerFrame).toBeInTheDocument();
});
});

View File

@ -0,0 +1,105 @@
import React from 'react';
import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg';
import {isCookiesDisabled} from '../../utils/helpers';
export const ActionButtonStyles = `
.gh-portal-btn-main {
box-shadow: none;
position: relative;
border: none;
}
.gh-portal-btn-main:hover,
.gh-portal-btn-main:focus {
box-shadow: none;
border: none;
}
.gh-portal-btn-primary:hover,
.gh-portal-btn-primary:focus {
opacity: 0.92 !important;
}
.gh-portal-btn-primary:disabled:hover::before {
display: none;
}
.gh-portal-btn-destructive:not(:disabled):hover {
color: var(--red);
border-color: var(--red);
}
.gh-portal-btn-text {
padding: 0;
font-weight: 500;
height: unset;
border: none;
box-shadow: none;
}
.gh-portal-loadingicon {
position: absolute;
left: 50%;
display: inline-block;
margin-left: -19px;
height: 31px;
}
.gh-portal-loadingicon path,
.gh-portal-loadingicon rect {
fill: #fff;
}
.gh-portal-loadingicon.dark path,
.gh-portal-loadingicon.dark rect {
fill: #1d1d1d;
}
`;
const Styles = ({brandColor, retry, disabled, style = {}, isPrimary}) => {
let backgroundColor = (brandColor || '#3eb0ef');
let opacity = '1.0';
let pointerEvents = 'auto';
if (disabled) {
opacity = '0.5';
pointerEvents = 'none';
}
const textColor = '#fff';
return {
button: {
...(isPrimary ? {color: textColor} : {}),
...(isPrimary ? {backgroundColor} : {}),
opacity,
pointerEvents,
...(style || {}) // Override any custom style
}
};
};
function ActionButton({label, type = undefined, onClick, disabled = false, retry = false, brandColor, isRunning, isPrimary = true, isDestructive = false, classes = '', style = {}, tabindex = undefined}) {
let Style = Styles({disabled, retry, brandColor, style, isPrimary});
let className = 'gh-portal-btn';
if (isPrimary) {
className += ' gh-portal-btn-main gh-portal-btn-primary';
}
if (isDestructive) {
className += ' gh-portal-btn-destructive';
}
if (classes) {
className += (' ' + classes);
}
if (isCookiesDisabled()) {
disabled = true;
}
const loaderClassName = isPrimary ? 'gh-portal-loadingicon' : 'gh-portal-loadingicon dark';
return (
<button className={className} style={Style.button} onClick={e => onClick(e)} disabled={disabled} type='submit' tabIndex={tabindex}>
{isRunning ? <LoaderIcon className={loaderClassName} /> : label}
</button>
);
}
export default ActionButton;

View File

@ -0,0 +1,34 @@
import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import ActionButton from './ActionButton';
const setup = (overrides) => {
const mockOnClickFn = jest.fn();
const props = {
label: 'Test Action Button', onClick: mockOnClickFn, disabled: false
};
const utils = render(
<ActionButton {...props} />
);
const buttonEl = utils.queryByRole('button', {name: props.label});
return {
buttonEl,
mockOnClickFn,
...utils
};
};
describe('ActionButton', () => {
test('renders', () => {
const {buttonEl} = setup();
expect(buttonEl).toBeInTheDocument();
});
test('fires onClick', () => {
const {buttonEl, mockOnClickFn} = setup();
fireEvent.click(buttonEl);
expect(mockOnClickFn).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import {ReactComponent as LeftArrowIcon} from '../../images/icons/arrow-left.svg';
export const BackButtonStyles = `
.gh-portal-btn-back,
.gh-portal-btn-back:hover {
box-shadow: none;
position: relative;
height: unset;
min-width: unset;
position: fixed;
top: 29px;
left: 25px;
background: none;
padding: 8px;
margin: 0;
box-shadow: none;
color: var(--grey3);
border: none;
z-index: 10000;
}
.gh-portal-btn-back:hover {
color: var(--grey1);
transform: translateX(-4px);
}
.gh-portal-btn-back svg {
width: 17px;
height: 17px;
margin-top: 1px;
margin-right: 2px;
}
`;
function ActionButton({label = 'Back', brandColor = '#3eb0ef', hidden = false, onClick}) {
if (hidden) {
return null;
}
return (
<button className='gh-portal-btn gh-portal-btn-back' onClick={e => onClick(e)}>
<LeftArrowIcon /> {label}
</button>
);
}
export default ActionButton;

View File

@ -0,0 +1,14 @@
import React from 'react';
import AppContext from '../../AppContext';
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
export default class CloseButton extends React.Component {
static contextType = AppContext;
render() {
return (
<div className='gh-portal-closeicon-container'>
<CloseIcon className='gh-portal-closeicon' alt='Close' onClick = {() => this.context.onAction('closePopup')} />
</div>
);
}
}

View File

@ -0,0 +1,161 @@
import React, {useEffect, useRef} from 'react';
import {hasMode} from '../../utils/check-mode';
import {isCookiesDisabled} from '../../utils/helpers';
export const InputFieldStyles = `
.gh-portal-input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
display: block;
box-sizing: border-box;
font-size: 1.5rem;
color: inherit;
background: transparent;
outline: none;
border: 1px solid var(--grey11);
border-radius: 6px;
width: 100%;
height: 44px;
padding: 0 12px;
margin-bottom: 18px;
letter-spacing: 0.2px;
transition: border-color 0.25s ease-in-out;
}
.gh-portal-input-labelcontainer {
display: flex;
justify-content: space-between;
width: 100%;
}
.gh-portal-input-labelcontainer p {
color: var(--red);
font-size: 1.3rem;
letter-spacing: 0.35px;
line-height: 1.6em;
margin-bottom: 0;
}
.gh-portal-input-label.hidden {
display: none;
}
.gh-portal-input:focus {
border-color: var(--grey8);
}
.gh-portal-input.error {
border-color: var(--red);
}
.gh-portal-input::placeholder {
color: var(--grey8);
}
.gh-portal-popup-container:not(.preview) .gh-portal-input:disabled {
background: var(--grey13);
color: var(--grey9);
box-shadow: none;
}
.gh-portal-popup-container:not(.preview) .gh-portal-input:disabled::placeholder {
color: var(--grey9);
}
`;
function InputError({message, style}) {
if (!message) {
return null;
}
return (
<p style={{
...(style || {})
}}>
{message}
</p>
);
}
function InputField({
name,
id,
label,
hideLabel,
type,
value,
placeholder,
disabled = false,
onChange = () => {},
onBlur = () => {},
onKeyDown = () => {},
tabindex,
maxlength,
autoFocus,
errorMessage
}) {
const fieldNode = useRef(null);
id = id || `input-${name}`;
const labelClasses = hideLabel ? 'gh-portal-input-label hidden' : 'gh-portal-input-label';
const inputClasses = errorMessage ? 'gh-portal-input error' : 'gh-portal-input';
if (isCookiesDisabled()) {
disabled = true;
}
// Disable all input fields in preview mode
if (hasMode(['preview'])) {
disabled = true;
}
let autocomplete = '';
let autocorrect = '';
let autocapitalize = '';
switch (id) {
case 'input-email':
autocomplete = 'off';
autocorrect = 'off';
autocapitalize = 'off';
break;
case 'input-name':
autocomplete = 'off';
autocorrect = 'off';
break;
default:
break;
}
useEffect(() => {
if (autoFocus) {
fieldNode.current.focus();
}
}, [autoFocus]);
return (
<section className='gh-portal-input-section'>
<div className='gh-portal-input-labelcontainer'>
<label htmlFor={id} className={labelClasses}> {label} </label>
<InputError message={errorMessage} name={name} />
</div>
<input
ref={fieldNode}
id={id}
className={inputClasses}
type={type}
name={name}
value={value}
placeholder={placeholder}
onChange={e => onChange(e, name)}
onKeyDown={e => onKeyDown(e, name)}
onBlur={e => onBlur(e, name)}
disabled={disabled}
tabIndex={tabindex}
maxLength={maxlength}
autoComplete={autocomplete}
autoCorrect={autocorrect}
autoCapitalize={autocapitalize}
aria-label={label}
/>
</section>
);
}
export default InputField;

View File

@ -0,0 +1,38 @@
import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import InputField from './InputField';
const setup = (overrides = {}) => {
const mockOnChangeFn = jest.fn();
const props = {
name: 'test-input',
label: 'Test Input',
value: '',
placeholder: 'Test placeholder',
onChange: mockOnChangeFn
};
const utils = render(
<InputField {...props} />
);
const inputEl = utils.getByLabelText(props.label);
return {
inputEl,
mockOnChangeFn,
...utils
};
};
describe('InputField', () => {
test('renders', () => {
const {inputEl} = setup();
expect(inputEl).toBeInTheDocument();
});
test('calls onChange on value', () => {
const {inputEl, mockOnChangeFn} = setup();
fireEvent.change(inputEl, {target: {value: 'Test'}});
expect(mockOnChangeFn).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
import React, {Component} from 'react';
import InputField from './InputField';
const FormInput = ({field, onChange, onBlur = () => { }, onKeyDown = () => {}}) => {
if (!field) {
return null;
}
return (
<>
<InputField
key={field.name}
label = {field.label}
type={field.type}
name={field.name}
placeholder={field.placeholder}
disabled={field.disabled}
value={field.value}
onKeyDown={onKeyDown}
onChange={e => onChange(e, field)}
onBlur={e => onBlur(e, field)}
tabIndex={field.tabindex}
errorMessage={field.errorMessage}
autoFocus={field.autoFocus}
/>
</>
);
};
class InputForm extends Component {
constructor(props) {
super(props);
this.state = { };
}
render() {
const {fields, onChange, onBlur, onKeyDown} = this.props;
const inputFields = fields.map((field) => {
return <FormInput field={field} key={field.name} onChange={onChange} onBlur={onBlur} onKeyDown={onKeyDown} />;
});
return (
<>
{inputFields}
</>
);
}
}
export default InputForm;

View File

@ -0,0 +1,56 @@
import React from 'react';
import {ReactComponent as UserIcon} from '../../images/icons/user.svg';
export const AvatarStyles = `
.gh-portal-avatar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin: 0 0 8px 0;
border-radius: 999px;
}
.gh-portal-avatar img {
position: absolute;
display: block;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
width: calc(100% + 4px);
height: calc(100% + 4px);
opacity: 1;
max-width: unset;
}
`;
const Styles = ({style = {}}) => {
return {
avatarContainer: {
...(style.avatarContainer || {}) // Override any custom style
},
gravatar: {
...(style.avatarContainer || {}) // Override any custom style
},
userIcon: {
width: '34px',
height: '34px',
color: '#fff',
...(style.userIcon || {}) // Override any custom style
}
};
};
function MemberGravatar({gravatar, style}) {
let Style = Styles({style});
return (
<figure className='gh-portal-avatar' style={Style.avatarContainer}>
<UserIcon style={Style.userIcon} />
{gravatar ? <img style={Style.gravatar} src={gravatar} alt="" /> : null}
</figure>
);
}
export default MemberGravatar;

View File

@ -0,0 +1,31 @@
import React from 'react';
import {render} from '@testing-library/react';
import MemberGravatar from './MemberGravatar';
const setup = (overrides = {}) => {
const props = {
gravatar: 'https://gravatar.com/avatar/76a4c5450dbb6fde8a293a811622aa6f?s=250&d=blank'
};
const utils = render(
<MemberGravatar {...props} />
);
const figureEl = utils.container.querySelector('figure');
const userIconEl = utils.container.querySelector('svg');
const imgEl = utils.container.querySelector('img');
return {
figureEl,
userIconEl,
imgEl,
...utils
};
};
describe('MemberGravatar', () => {
test('renders', () => {
const {figureEl, userIconEl, imgEl} = setup();
expect(figureEl).toBeInTheDocument();
expect(userIconEl).toBeInTheDocument();
expect(imgEl).toBeInTheDocument();
});
});

View File

@ -0,0 +1,204 @@
import AppContext from '../../AppContext';
import CloseButton from '../common/CloseButton';
import BackButton from '../common/BackButton';
import {useContext, useState} from 'react';
import Switch from '../common/Switch';
import {getSiteNewsletters} from '../../utils/helpers';
import ActionButton from '../common/ActionButton';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/check-circle.svg';
const React = require('react');
function AccountHeader() {
const {brandColor, lastPage, onAction} = useContext(AppContext);
return (
<header className='gh-portal-detail-header'>
<BackButton brandColor={brandColor} hidden={!lastPage} onClick={(e) => {
onAction('back');
}} />
<h3 className='gh-portal-main-title'>Email preferences</h3>
</header>
);
}
function SuccessIcon({show, checked}) {
let classNames = [];
if (show) {
classNames.push('gh-portal-checkmark-show');
}
if (checked) {
classNames.push('gh-portal-toggle-checked');
}
classNames.push('gh-portal-checkmark-container');
return (
<div className={classNames.join(' ')}>
<CheckmarkIcon className='gh-portal-checkmark-icon' alt='' />
</div>
);
}
function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribedNewsletters}) {
const isChecked = subscribedNewsletters.some((d) => {
return d.id === newsletter?.id;
});
const [showUpdated, setShowUpdated] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
return (
<section className='gh-portal-list-toggle-wrapper'>
<div className='gh-portal-list-detail'>
<h3>{newsletter.name}</h3>
<p>{newsletter?.description}</p>
</div>
<div style={{display: 'flex', alignItems: 'center'}}>
<SuccessIcon show={showUpdated} checked={isChecked} />
<Switch id={newsletter.id} onToggle={(e, checked) => {
let updatedNewsletters = [];
if (!checked) {
updatedNewsletters = subscribedNewsletters.filter((d) => {
return d.id !== newsletter.id;
});
setShowUpdated(true);
clearTimeout(timeoutId);
let newTimeoutId = setTimeout(() => {
setShowUpdated(false);
}, 2000);
setTimeoutId(newTimeoutId);
} else {
updatedNewsletters = subscribedNewsletters.filter((d) => {
return d.id !== newsletter.id;
}).concat(newsletter);
setShowUpdated(true);
clearTimeout(timeoutId);
let newTimeoutId = setTimeout(() => {
setShowUpdated(false);
}, 2000);
setTimeoutId(newTimeoutId);
}
setSubscribedNewsletters(updatedNewsletters);
}} checked={isChecked} />
</div>
</section>
);
}
function CommentsSection({updateCommentNotifications, isCommentsEnabled, enableCommentNotifications}) {
const isChecked = !!enableCommentNotifications;
const [showUpdated, setShowUpdated] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
if (!isCommentsEnabled) {
return null;
}
return (
<section className='gh-portal-list-toggle-wrapper'>
<div className='gh-portal-list-detail'>
<h3>Comments</h3>
<p>Get notified when someone replies to your comment</p>
</div>
<div style={{display: 'flex', alignItems: 'center'}}>
<SuccessIcon show={showUpdated} checked={isChecked} />
<Switch id="comments" onToggle={(e, checked) => {
setShowUpdated(true);
clearTimeout(timeoutId);
let newTimeoutId = setTimeout(() => {
setShowUpdated(false);
}, 2000);
setTimeoutId(newTimeoutId);
updateCommentNotifications(checked);
}} checked={isChecked} />
</div>
</section>
);
}
function NewsletterPrefs({subscribedNewsletters, setSubscribedNewsletters}) {
const {site} = useContext(AppContext);
const newsletters = getSiteNewsletters({site});
return newsletters.map((newsletter) => {
return (
<NewsletterPrefSection
key={newsletter?.id}
newsletter={newsletter}
subscribedNewsletters={subscribedNewsletters}
setSubscribedNewsletters={setSubscribedNewsletters}
/>
);
});
}
function ShowPaidMemberMessage({site, isPaid}) {
if (isPaid) {
return (
<p style={{textAlign: 'center', marginTop: '12px', marginBottom: '0', color: 'var(--grey6)'}}>Unsubscribing from emails will not cancel your paid subscription to {site?.title}</p>
);
}
return null;
}
export default function NewsletterManagement({
notification,
subscribedNewsletters,
updateSubscribedNewsletters,
updateCommentNotifications,
unsubscribeAll,
isPaidMember,
isCommentsEnabled,
enableCommentNotifications
}) {
const {brandColor, site} = useContext(AppContext);
const isDisabled = !subscribedNewsletters?.length && ((isCommentsEnabled && !enableCommentNotifications) || !isCommentsEnabled);
const EmptyNotification = () => {
return null;
};
const FinalNotification = notification || EmptyNotification;
return (
<div className='gh-portal-content with-footer'>
<CloseButton />
<AccountHeader />
<FinalNotification />
<div className='gh-portal-section'>
<div className='gh-portal-list'>
<NewsletterPrefs
subscribedNewsletters={subscribedNewsletters}
setSubscribedNewsletters={(updatedNewsletters) => {
let newsletters = updatedNewsletters.map((d) => {
return {
id: d.id
};
});
updateSubscribedNewsletters(newsletters);
}}
/>
<CommentsSection
isCommentsEnabled={isCommentsEnabled}
enableCommentNotifications={enableCommentNotifications}
updateCommentNotifications={updateCommentNotifications}
/>
</div>
</div>
<footer className='gh-portal-action-footer'>
<div style={{width: '100%'}}>
<ActionButton
isRunning={false}
onClick={(e) => {
unsubscribeAll();
}}
disabled={isDisabled}
brandColor={brandColor}
isPrimary={false}
label='Unsubscribe from all emails'
isDestructive={true}
style={{width: '100%'}}
/>
<ShowPaidMemberMessage isPaid={isPaidMember} site={site} />
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,407 @@
import React, {useContext} from 'react';
import AppContext from '../../AppContext';
import calculateDiscount from '../../utils/discount';
import {isCookiesDisabled, formatNumber, hasOnlyFreePlan} from '../../utils/helpers';
import ProductsSection, {ChangeProductSection} from './ProductsSection';
export const PlanSectionStyles = `
.gh-portal-plans-container {
display: flex;
align-items: stretch;
border: 1px solid var(--grey11);
border-radius: 5px;
}
.gh-portal-plan-section {
display: flex;
flex-direction: column;
flex: 1;
position: relative;
align-items: center;
justify-items: center;
font-size: 1.4rem;
line-height: 1.35em;
border-right: 1px solid var(--grey11);
padding: 24px 10px;
cursor: pointer;
user-select: none;
}
.gh-portal-change-plan-section {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.gh-portal-plans-container.disabled .gh-portal-plan-section {
cursor: auto;
}
.gh-portal-plan-section.checked::before {
position: absolute;
display: block;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
content: "";
z-index: 999;
border: 2px solid var(--brandcolor);
pointer-events: none;
}
.gh-portal-plan-section:first-of-type::before {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.gh-portal-plan-section:last-of-type::before {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.gh-portal-plan-section:last-of-type {
border-right: none;
}
.gh-portal-plans-container:not(.empty-selected-benefits) .gh-portal-plan-section::before {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.gh-portal-plans-container.has-discount {
margin-top: 40px;
}
.gh-portal-plans-container.has-discount,
.gh-portal-plans-container.has-discount .gh-portal-plan-section:last-of-type::before {
border-top-right-radius: 0;
}
.gh-portal-plans-container.is-change-plan .gh-portal-plan-section::before {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.gh-portal-plans-container.disabled .gh-portal-plan-section.checked::before {
opacity: 0.3;
}
.gh-portal-plan-pricelabel {
display: flex;
flex-direction: row;
min-height: 28px;
margin-top: 2px;
}
.gh-portal-plans-container .gh-portal-plan-pricelabel {
min-height: unset;
}
.gh-portal-plan-pricecontainer {
display: flex;
}
.gh-portal-plan-priceinterval {
font-size: 1.25rem;
line-height: 2;
color: var(--grey7);
}
.gh-portal-plan-name {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 500;
line-height: 1.0em;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-top: 4px;
text-align: center;
min-height: 24px;
word-break: break-word;
}
.gh-portal-plan-currency {
position: relative;
bottom: 2px;
font-size: 1.4rem;
font-weight: 500;
letter-spacing: 0.4px;
}
.gh-portal-plan-currency-code {
margin-right: 2px;
font-size: 1.15rem;
}
.gh-portal-plan-price {
font-size: 2.2rem;
font-weight: 500;
letter-spacing: 0.1px;
}
.gh-portal-plan-type {
color: var(--grey7);
}
.gh-portal-plan-featurewrapper {
display: flex;
flex-direction: column;
align-items: center;
border-top: 1px solid var(--grey12);
padding-top: 12px;
width: 100%;
}
.gh-portal-plan-feature {
font-size: 1.25rem;
font-weight: 500;
line-height: 1.25em;
text-align: center;
letter-spacing: 0.2px;
word-break: break-word;
}
.gh-portal-content.signup.singleplan .gh-portal-plan-section {
cursor: auto;
}
.gh-portal-content.signup.singleplan .gh-portal-plan-section.checked::before {
display: none;
}
.gh-portal-content.signup.singleplan .gh-portal-plan-name {
margin-top: 0;
}
.gh-portal-plan-section:not(.checked)::before {
position: absolute;
display: block;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
content: "";
z-index: 999;
border: 1px solid var(--brandcolor);
pointer-events: none;
opacity: 0;
transition: all 0.2s ease-in-out;
}
.gh-portal-plans-container.disabled .gh-portal-plan-section:not(.checked):hover::before {
opacity: 0;
}
.gh-portal-plans-container.hide-checkbox .gh-portal-plan-section {
padding-top: 12px;
padding-bottom: 12px;
}
.gh-portal-plan-current {
display: block;
font-size: 1.25rem;
letter-spacing: 0.2px;
line-height: 1.25em;
color: var(--brandcolor);
margin: 3px 0 -2px;
}
.gh-portal-plans-container:not(.empty-selected-benefits) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.gh-portal-plans-container.is-change-plan {
border-radius: 0 0 5px 5px;
border-top: none;
}
.gh-portal-plans-container.is-change-plan .gh-portal-plan-section {
min-height: 90px;
}
.gh-portal-plan-product {
border: 1px solid var(--grey11);
border-radius: 5px;
}
.gh-portal-plan-product:not(:last-of-type) {
margin-bottom: 20px;
}
.gh-portal-plan-productname {
border-radius: 5px 5px 0 0;
padding: 2px 10px;
font-size: 1.25rem;
letter-spacing: 0.3px;
text-transform: uppercase;
font-weight: 600;
border-bottom: 1px solid var(--grey12);
}
.gh-portal-accountplans-main .gh-portal-plan-section:hover:not(.checked) {
background: var(--grey14);
}
.gh-portal-accountplans-main .gh-portal-plan-section:last-of-type {
border-radius: 0 0 5px 5px;
}
.gh-portal-singleproduct-benefits {
display: flex;
flex-direction: column;
border: 1px solid var(--grey11);
border-top: none !important;
margin: 0 0 4px !important;
padding: 16px 24px 12px !important;
border-radius: 0 0 5px 5px;
}
.gh-portal-singleproduct-benefits.onlyfree {
border-top: 1px solid var(--grey11) !important;
border-radius: 5px;
margin-top: 30px !important;
}
.gh-portal-singleproduct-benefits .gh-portal-product-benefit {
padding: 0 8px;
}
.gh-portal-singleproduct-benefits .gh-portal-product-benefit:last-of-type {
margin-bottom: 16px;
}
.gh-portal-singleproduct-benefits.onlyfree .gh-portal-product-benefit:last-of-type {
margin-bottom: 4px;
}
.gh-portal-singleproduct-benefits:not(.no-benefits) .gh-portal-product-description {
border-bottom: 1px solid var(--grey12);
padding-bottom: 20px;
margin-bottom: 16px;
}
`;
function PriceLabel({currencySymbol, price, interval}) {
const isSymbol = currencySymbol.length !== 3;
const currencyClass = isSymbol ? 'gh-portal-plan-currency gh-portal-plan-currency-symbol' : 'gh-portal-plan-currency gh-portal-plan-currency-code';
return (
<div className='gh-portal-plan-pricelabel'>
<div className='gh-portal-plan-pricecontainer'>
<span className={currencyClass}>{currencySymbol}</span>
<span className='gh-portal-plan-price'>{formatNumber(price)}</span>
</div>
</div>
);
}
function addDiscountToPlans(plans) {
const filteredPlans = plans.filter(d => d.id !== 'free');
const monthlyPlan = plans.find((d) => {
return d.name === 'Monthly' && d.interval === 'month';
});
const yearlyPlan = plans.find((d) => {
return d.name === 'Yearly' && d.interval === 'year';
});
if (filteredPlans.length === 2 && monthlyPlan && yearlyPlan) {
const discount = calculateDiscount(monthlyPlan.amount, yearlyPlan.amount);
yearlyPlan.description = discount > 0 ? `${discount}% discount` : '';
monthlyPlan.description = '';
}
}
export function MultipleProductsPlansSection({products, selectedPlan, onPlanSelect, onPlanCheckout, changePlan = false}) {
const cookiesDisabled = isCookiesDisabled();
/**Don't allow plans selection if cookies are disabled */
if (cookiesDisabled) {
onPlanSelect = () => {};
}
if (changePlan) {
return (
<section className="gh-portal-plans">
<div>
<ChangeProductSection
type='changePlan'
products={products}
selectedPlan={selectedPlan}
onPlanSelect={onPlanSelect}
/>
</div>
</section>
);
}
return (
<section className="gh-portal-plans">
<div>
<ProductsSection
type='upgrade'
products={products}
onPlanSelect={onPlanSelect}
handleChooseSignup={(...args) => {
onPlanCheckout(...args);
}}
/>
</div>
</section>
);
}
function getChangePlanClassNames({cookiesDisabled, site}) {
let className = 'gh-portal-plans-container is-change-plan hide-checkbox';
if (cookiesDisabled) {
className += ' disabled';
}
return className;
}
function ChangePlanOptions({plans, selectedPlan, onPlanSelect, changePlan}) {
addDiscountToPlans(plans);
return plans.map(({name, currency_symbol: currencySymbol, amount, description, interval, id}) => {
const price = amount / 100;
const isChecked = selectedPlan === id;
let displayName = interval === 'month' ? 'Monthly' : 'Yearly';
let planClass = (isChecked ? 'gh-portal-plan-section checked' : 'gh-portal-plan-section');
planClass += ' gh-portal-change-plan-section';
const planNameClass = 'gh-portal-plan-name no-description';
const featureClass = 'gh-portal-plan-featurewrapper';
return (
<div className={planClass} key={id} onClick={e => onPlanSelect(e, id)}>
<h4 className={planNameClass}>{displayName}</h4>
<PriceLabel currencySymbol={currencySymbol} price={price} interval={interval} />
<div className={featureClass} style={{border: 'none', paddingTop: '3px'}}>
{(changePlan && selectedPlan === id ? <span className='gh-portal-plan-current'>Current plan</span> : '')}
</div>
</div>
);
});
}
export function ChangeProductPlansSection({product, plans, selectedPlan, onPlanSelect, changePlan = false}) {
const {site} = useContext(AppContext);
if (!product || hasOnlyFreePlan({plans})) {
return null;
}
const cookiesDisabled = isCookiesDisabled();
/**Don't allow plans selection if cookies are disabled */
if (cookiesDisabled) {
onPlanSelect = () => {};
}
const className = getChangePlanClassNames({cookiesDisabled, site});
return (
<section className="gh-portal-plans">
<div className={className}>
<ChangePlanOptions plans={plans} onPlanSelect={onPlanSelect} selectedPlan={selectedPlan} changePlan={changePlan} />
</div>
</section>
);
}

View File

@ -0,0 +1,204 @@
import React from 'react';
import AppContext from '../../AppContext';
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark-fill.svg';
import {ReactComponent as WarningIcon} from '../../images/icons/warning-fill.svg';
import {getSupportAddress} from '../../utils/helpers';
import {clearURLParams} from '../../utils/notifications';
export const PopupNotificationStyles = `
.gh-portal-popupnotification {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
padding: 12px;
background: var(--grey2);
z-index: 11000;
border-radius: 5px;
font-size: 1.5rem;
box-shadow: 0px 0.8151839971542358px 0.8151839971542358px 0px rgba(0,0,0,0.01),
0px 2.2538793087005615px 2.2538793087005615px 0px rgba(0,0,0,0.02),
0px 5.426473140716553px 5.426473140716553px 0px rgba(0,0,0,0.03),
0px 18px 18px 0px rgba(0,0,0,0.04);
animation: popupnotification-slidein 0.3s ease-in-out;
}
.gh-portal-popupnotification.slideout {
animation: popupnotification-slideout 0.48s ease-in;
}
.gh-portal-popupnotification p {
color: var(--white);
margin: 0;
padding: 0 20px;
font-size: 1.5rem;
line-height: 1.5em;
letter-spacing: 0.2px;
text-align: center;
}
.gh-portal-popupnotification a {
color: var(--white);
}
.gh-portal-popupnotification-icon {
position: absolute;
top: 12px;
left: 12px;
width: 20px;
height: 20px;
}
.gh-portal-popupnotification-icon.success {
color: var(--green);
}
.gh-portal-popupnotification-icon.error {
color: #FF2828;
}
.gh-portal-popupnotification .closeicon {
position: absolute;
top: 3px;
bottom: 0;
right: 3px;
color: var(--white);
cursor: pointer;
width: 16px;
height: 16px;
padding: 12px;
transition: all 0.15s ease-in-out forwards;
opacity: 0.8;
}
.gh-portal-popupnotification .closeicon:hover {
opacity: 1.0;
}
@keyframes popupnotification-slidein {
0% {
transform: translateY(-10px);
opacity: 0;
}
60% { transform: translateY(2px); }
100% {
transform: translateY(0);
opacity: 1.0;
}
}
@keyframes popupnotification-slideout {
0% {
transform: translateY(0);
opacity: 1.0;
}
40% { transform: translateY(2px); }
100% {
transform: translateY(-10px);
opacity: 0;
}
}
`;
const CloseButton = ({hide = false, onClose}) => {
if (hide) {
return null;
}
return (
<CloseIcon className='closeicon' alt='Close' onClick={onClose} />
);
};
const NotificationText = ({message, site}) => {
const supportAddress = getSupportAddress({site});
const supportAddressMail = `mailto:${supportAddress}`;
if (message) {
return (
<p>{message}</p>
);
}
return (
<p> An unexpected error occured. Please try again or <a href={supportAddressMail} onClick={() => {
supportAddressMail && window.open(supportAddressMail);
}}>contact support</a> if the error persists.</p>
);
};
export default class PopupNotification extends React.Component {
static contextType = AppContext;
constructor() {
super();
this.state = {
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');
}
}
closeNotification(e) {
this.context.onAction('clearPopupNotification');
}
componentDidUpdate() {
const {popupNotification} = this.context;
if (popupNotification.count !== this.state.notificationCount) {
clearTimeout(this.timeoutId);
this.handlePopupNotification({popupNotification});
}
}
handlePopupNotification({popupNotification}) {
this.setState({
notificationCount: popupNotification.count
});
if (popupNotification.autoHide) {
const {duration = 2600} = popupNotification;
this.timeoutId = setTimeout(() => {
this.setState((state) => {
if (state.className !== 'slideout') {
return {
className: 'slideout',
notificationCount: popupNotification.count
};
}
return {};
});
}, duration);
}
}
componentDidMount() {
const {popupNotification} = this.context;
this.handlePopupNotification({popupNotification});
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
render() {
const {popupNotification, site} = this.context;
const {className} = this.state;
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)}>
{(status === 'error' ? <WarningIcon className='gh-portal-popupnotification-icon error' alt=''/> : <CheckmarkIcon className='gh-portal-popupnotification-icon success' alt=''/>)}
<NotificationText type={type} status={status} message={message} site={site} />
<CloseButton hide={!closeable} onClose={e => this.closeNotification(e)}/>
</div>
);
}
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import {ReactComponent as GhostLogo} from '../../images/ghost-logo-small.svg';
export default class PoweredBy extends React.Component {
render() {
return (
<a href='https://ghost.org' target='_blank' rel='noopener noreferrer' onClick={() => {
window.open('https://ghost.org', '_blank');
}}>
<GhostLogo />
Powered by Ghost
</a>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
import React from 'react';
import AppContext from '../../AppContext';
export default class SiteTitleBackButton extends React.Component {
static contextType = AppContext;
render() {
// const {site} = this.context;
return (
<>
<button
className='gh-portal-btn gh-portal-btn-site-title-back'
onClick = {() => {
if (this.props.onBack) {
this.props.onBack();
} else {
this.context.onAction('closePopup');
}
}}>
<span>&larr; </span> Back
</button>
</>
);
}
}

View File

@ -0,0 +1,101 @@
import React, {useContext, useEffect, useState} from 'react';
import AppContext from '../../AppContext';
export const SwitchStyles = `
.gh-portal-for-switch label,
.gh-portal-for-switch .container {
position: relative;
display: inline-block;
width: 44px !important;
height: 26px !important;
cursor: pointer;
}
.gh-portal-for-switch label p,
.gh-portal-for-switch .container p {
overflow: auto;
color: var(--grey0);
font-weight: normal;
}
.gh-portal-for-switch input {
opacity: 0;
width: 0;
height: 0;
}
.gh-portal-for-switch .input-toggle-component {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #e9e9e9;
transition: .3s;
width: 44px !important;
height: 26px !important;
border-radius: 999px;
transition: background 0.15s ease-in-out, border-color 0.15s ease-in-out;
cursor: pointer;
}
.gh-portal-for-switch label:hover input:not(:checked) + .input-toggle-component,
.gh-portal-for-switch .container:hover input:not(:checked) + .input-toggle-component {
border-color: var(--grey9);
}
.gh-portal-for-switch .input-toggle-component:before {
position: absolute;
content: "";
top: 3px !important;
left: 3px !important;
height: 20px !important;
width: 20px !important;
background-color: white;
transition: .3s;
border-radius: 999px;
}
.gh-portal-for-switch input:checked + .input-toggle-component {
background: var(--brandcolor);
border-color: transparent;
}
.gh-portal-for-switch input:checked + .input-toggle-component:before {
transform: translateX(18px);
box-shadow: none;
}
.gh-portal-for-switch .container {
width: 38px !important;
height: 22px !important;
}
`;
function Switch({id, label = '', onToggle, checked = false}) {
const {action} = useContext(AppContext);
const [isChecked, setIsChecked] = useState(checked);
const isActionChanged = ['updateNewsletter:failed', 'updateNewsletter:success'].includes(action);
useEffect(() => {
setIsChecked(checked);
}, [checked, isActionChanged]);
return (
<div className="gh-portal-for-switch">
<label className="switch" htmlFor={id}>
<input
type="checkbox"
checked={isChecked}
id={id}
onChange={() => {}}
aria-label={label}
/>
<span className="input-toggle-component" onClick={(e) => {
setIsChecked(!isChecked);
onToggle(e, !isChecked);
}} data-testid="switch-input"></span>
</label>
</div>
);
}
export default Switch;

View File

@ -0,0 +1,36 @@
import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import Switch from './Switch';
const setup = (overrides = {}) => {
const mockOnToggle = jest.fn();
const props = {
onToggle: mockOnToggle,
label: 'Test Switch',
id: 'test-switch'
};
const utils = render(
<Switch {...props} />
);
const checkboxEl = utils.getByTestId('switch-input');
return {
checkboxEl,
mockOnToggle,
...utils
};
};
describe('Switch', () => {
test('renders', () => {
const {checkboxEl} = setup();
expect(checkboxEl).toBeInTheDocument();
});
test('calls onToggle on click', () => {
const {checkboxEl, mockOnToggle} = setup();
fireEvent.click(checkboxEl);
expect(mockOnToggle).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,55 @@
import AppContext from '../../AppContext';
import {useContext, useEffect, useState} from 'react';
import {isPaidMember} from '../../utils/helpers';
import NewsletterManagement from '../common/NewsletterManagement';
const React = require('react');
export default function AccountEmailPage() {
const {member, onAction, site} = useContext(AppContext);
const defaultSubscribedNewsletters = [...(member?.newsletters || [])];
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultSubscribedNewsletters);
const {comments_enabled: commentsEnabled} = site;
const {enable_comment_notifications: enableCommentNotifications} = member;
useEffect(() => {
if (!member) {
onAction('switchPage', {
page: 'signin'
});
}
}, [member, onAction]);
useEffect(() => {
setSubscribedNewsletters(member?.newsletters || []);
}, [member?.newsletters]);
return (
<NewsletterManagement
notification={null}
subscribedNewsletters={subscribedNewsletters}
updateSubscribedNewsletters={(updatedNewsletters) => {
setSubscribedNewsletters(updatedNewsletters);
onAction('updateNewsletterPreference', {newsletters: updatedNewsletters});
}}
updateCommentNotifications={async (enabled) => {
onAction('updateNewsletterPreference', {enableCommentNotifications: enabled});
}}
unsubscribeAll={() => {
setSubscribedNewsletters([]);
onAction('showPopupNotification', {
action: 'updated:success',
message: `Email preference updated.`
});
const data = {newsletters: []};
if (commentsEnabled) {
data.enableCommentNotifications = false;
}
onAction('updateNewsletterPreference', data);
}}
isPaidMember={isPaidMember({member})}
isCommentsEnabled={commentsEnabled !== 'off'}
enableCommentNotifications={enableCommentNotifications}
/>
);
}

View File

@ -0,0 +1,615 @@
import AppContext from '../../AppContext';
import MemberAvatar from '../common/MemberGravatar';
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
import Switch from '../common/Switch';
import {allowCompMemberUpgrade, getCompExpiry, getMemberSubscription, getMemberTierName, getSiteNewsletters, getSupportAddress, getUpdatedOfferPrice, hasCommentsEnabled, hasMultipleNewsletters, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, subscriptionHasFreeTrial} from '../../utils/helpers';
import {getDateString} from '../../utils/date-time';
import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg';
import {ReactComponent as OfferTagIcon} from '../../images/icons/offer-tag.svg';
import {useContext} from 'react';
const React = require('react');
export const AccountHomePageStyles = `
.gh-portal-account-header {
display: flex;
flex-direction: column;
align-items: center;
margin: 0 0 32px;
}
.gh-portal-account-header .gh-portal-avatar {
margin: 6px 0 8px !important;
}
.gh-portal-account-data {
margin-bottom: 40px;
}
footer.gh-portal-account-footer {
display: flex;
}
.gh-portal-account-footer.paid {
margin-top: 12px;
}
.gh-portal-account-footermenu {
display: flex;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
}
.gh-portal-account-footerright {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: flex-end;
}
.gh-portal-account-footermenu li {
margin-right: 16px;
}
.gh-portal-account-footermenu li:last-of-type {
margin-right: 0;
}
.gh-portal-freeaccount-newsletter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24px;
}
.gh-portal-freeaccount-newsletter .label {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.gh-portal-free-ctatext {
margin-top: -12px;
}
.gh-portal-cancelcontinue-container {
margin: 24px 0 32px;
}
.gh-portal-billing-button-loader {
width: 32px;
height: 32px;
margin-right: -3px;
opacity: 0.6;
}
.gh-portal-product-icon {
width: 52px;
margin-right: 12px;
border-radius: 2px;
}
.gh-portal-account-discountcontainer {
position: relative;
display: flex;
align-items: center;
}
.gh-portal-account-old-price {
text-decoration: line-through;
color: var(--grey9) !important;
}
.gh-portal-account-tagicon {
width: 16px;
height: 16px;
color: var(--brandcolor);
margin-right: 5px;
z-index: 999;
}
@media (max-width: 390px) {
.gh-portal-account-footer {
padding: 0 !important;
}
}
@media (max-width: 340px) {
.gh-portal-account-footer {
padding: 0 !important;
flex-wrap: wrap;
gap: 12px;
}
.gh-portal-account-footer .gh-portal-account-footerright {
justify-content: flex-start;
}
}
`;
const UserAvatar = ({avatar, brandColor}) => {
return (
<>
<MemberAvatar gravatar={avatar} style={{userIcon: {color: brandColor, width: '56px', height: '56px', padding: '2px'}}} />
</>
);
};
const AccountFooter = ({onClose, handleSignout, supportAddress = ''}) => {
const supportAddressMail = `mailto:${supportAddress}`;
return (
<footer className='gh-portal-account-footer'>
<ul className='gh-portal-account-footermenu'>
<li><button className='gh-portal-btn' name='logout' aria-label='logout' onClick={e => handleSignout(e)}>Sign out</button></li>
</ul>
<div className='gh-portal-account-footerright'>
<ul className='gh-portal-account-footermenu'>
<li><a className='gh-portal-btn gh-portal-btn-branded' href={supportAddressMail} onClick={() => {
supportAddressMail && window.open(supportAddressMail);
}}>Contact support</a></li>
</ul>
</div>
</footer>
);
};
const UserHeader = () => {
const {member, brandColor} = useContext(AppContext);
const avatar = member.avatar_image;
return (
<header className='gh-portal-account-header'>
<UserAvatar avatar={avatar} brandColor={brandColor} />
<h2 className="gh-portal-main-title">Your account</h2>
</header>
);
};
function getOfferLabel({offer, price, subscriptionStartDate}) {
let offerLabel = '';
if (offer) {
const discountDuration = offer.duration;
let durationLabel = '';
if (discountDuration === 'forever') {
durationLabel = `Forever`;
} else if (discountDuration === 'repeating') {
const durationInMonths = offer.duration_in_months || 0;
let offerStartDate = new Date(subscriptionStartDate);
let offerEndDate = new Date(offerStartDate.setMonth(offerStartDate.getMonth() + durationInMonths));
durationLabel = `Ends ${getDateString(offerEndDate)}`;
}
offerLabel = `${getUpdatedOfferPrice({offer, price, useFormatted: true})}/${price.interval}${durationLabel ? `${durationLabel}` : ``}`;
}
return offerLabel;
}
function FreeTrialLabel({subscription, priceLabel}) {
if (subscriptionHasFreeTrial({sub: subscription})) {
const trialEnd = getDateString(subscription.trial_end_at);
return (
<p className="gh-portal-account-discountcontainer">
<div>
<span>Free Trial Ends {trialEnd}</span>
{/* <span>{getSubFreeTrialDaysLeft({sub: subscription})} days left</span> */}
</div>
</p>
);
}
return null;
}
const PaidAccountActions = () => {
const {member, site, onAction} = useContext(AppContext);
const onEditBilling = () => {
const subscription = getMemberSubscription({member});
onAction('editBilling', {subscriptionId: subscription.id});
};
const openUpdatePlan = () => {
const {is_stripe_configured: isStripeConfigured} = site;
if (isStripeConfigured) {
onAction('switchPage', {
page: 'accountPlan',
lastPage: 'accountHome'
});
}
};
const PlanLabel = ({price, isComplimentary, subscription}) => {
const {
offer,
start_date: startDate
} = subscription || {};
let label = '';
if (price) {
const {amount = 0, currency, interval} = price;
label = `${Intl.NumberFormat('en', {currency, style: 'currency'}).format(amount / 100)}/${interval}`;
}
let offerLabelStr = getOfferLabel({price, offer, subscriptionStartDate: startDate});
const compExpiry = getCompExpiry({member});
if (isComplimentary) {
if (compExpiry) {
label = `Complimentary - Expires ${compExpiry}`;
} else {
label = label ? `Complimentary (${label})` : `Complimentary`;
}
}
let oldPriceClassName = '';
if (offerLabelStr) {
oldPriceClassName = 'gh-portal-account-old-price';
}
const OfferLabel = () => {
if (offerLabelStr) {
return (
<p className="gh-portal-account-discountcontainer">
<OfferTagIcon className="gh-portal-account-tagicon" />
<span>{offerLabelStr}</span>
</p>
);
}
return null;
};
const hasFreeTrial = subscriptionHasFreeTrial({sub: subscription});
if (hasFreeTrial) {
oldPriceClassName = 'gh-portal-account-old-price';
}
if (hasFreeTrial) {
return (
<>
<p className={oldPriceClassName}>
{label}
</p>
<FreeTrialLabel subscription={subscription} />
</>
);
}
return (
<>
<p className={oldPriceClassName}>
{label}
</p>
<OfferLabel />
</>
);
};
const PlanUpdateButton = ({isComplimentary}) => {
const hideUpgrade = allowCompMemberUpgrade({member}) ? false : isComplimentary;
if (hideUpgrade || hasOnlyFreePlan({site})) {
return null;
}
return (
<button className='gh-portal-btn gh-portal-btn-list' onClick={e => openUpdatePlan(e)}>Change</button>
);
};
const CardLabel = ({defaultCardLast4}) => {
if (defaultCardLast4) {
const label = `**** **** **** ${defaultCardLast4}`;
return (
<p>
{label}
</p>
);
}
return null;
};
const BillingSection = ({defaultCardLast4, isComplimentary}) => {
const {action} = useContext(AppContext);
const label = action === 'editBilling:running' ? (
<LoaderIcon className='gh-portal-billing-button-loader' />
) : 'Update';
if (isComplimentary) {
return null;
}
return (
<section>
<div className='gh-portal-list-detail'>
<h3>Billing info</h3>
<CardLabel defaultCardLast4={defaultCardLast4} />
</div>
<button className='gh-portal-btn gh-portal-btn-list' onClick={e => onEditBilling(e)}>{label}</button>
</section>
);
};
const subscription = getMemberSubscription({member});
const isComplimentary = isComplimentaryMember({member});
if (subscription || isComplimentary) {
const {
price,
default_payment_card_last4: defaultCardLast4
} = subscription || {};
let planLabel = 'Plan';
// Show name of tiers if there are multiple tiers
if (hasMultipleProductsFeature({site}) && getMemberTierName({member})) {
planLabel = getMemberTierName({member});
}
// const hasFreeTrial = subscriptionHasFreeTrial({sub: subscription});
// if (hasFreeTrial) {
// planLabel += ' (Free Trial)';
// }
return (
<>
<section>
<div className='gh-portal-list-detail'>
<h3>{planLabel}</h3>
<PlanLabel price={price} isComplimentary={isComplimentary} subscription={subscription} />
</div>
<PlanUpdateButton isComplimentary={isComplimentary} />
</section>
<BillingSection isComplimentary={isComplimentary} defaultCardLast4={defaultCardLast4} />
</>
);
}
return null;
};
const AccountActions = () => {
const {member, onAction} = useContext(AppContext);
const {name, email} = member;
const openEditProfile = () => {
onAction('switchPage', {
page: 'accountProfile',
lastPage: 'accountHome'
});
};
return (
<div>
<div className='gh-portal-list'>
<section>
<div className='gh-portal-list-detail'>
<h3>{(name ? name : 'Account')}</h3>
<p>{email}</p>
</div>
<button className='gh-portal-btn gh-portal-btn-list' onClick={e => openEditProfile(e)}>Edit</button>
</section>
<PaidAccountActions />
<EmailPreferencesAction />
<EmailNewsletterAction />
</div>
{/* <ProductList openUpdatePlan={openUpdatePlan}></ProductList> */}
</div>
);
};
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) => {
e.preventDefault();
const siteNewsletters = getSiteNewsletters({site});
const subscribedNewsletters = !member?.newsletters?.length ? siteNewsletters : [];
onAction('updateNewsletterPreference', {newsletters: subscribedNewsletters});
};
return (
<section>
<div className='gh-portal-list-detail'>
<h3>Email newsletter</h3>
<p>{label}</p>
</div>
<div>
<Switch
id="default-newsletter-toggle"
onToggle={(e) => {
onToggleSubscription(e, subscribed);
}} checked={subscribed}
/>
</div>
</section>
);
}
function EmailPreferencesAction() {
const {site, onAction} = useContext(AppContext);
if (!hasMultipleNewsletters({site}) && !hasCommentsEnabled({site})) {
return null;
}
return (
<section>
<div className='gh-portal-list-detail'>
<h3>Emails</h3>
<p>Update your preferences</p>
</div>
<button className='gh-portal-btn gh-portal-btn-list' onClick={(e) => {
onAction('switchPage', {
page: 'accountEmail',
lastPage: 'accountHome'
});
}}>Manage</button>
</section>
);
}
const SubscribeButton = () => {
const {site, action, brandColor, onAction} = useContext(AppContext);
const {is_stripe_configured: isStripeConfigured} = site;
if (!isStripeConfigured || hasOnlyFreePlan({site})) {
return null;
}
const isRunning = ['checkoutPlan:running'].includes(action);
const openPlanPage = () => {
onAction('switchPage', {
page: 'accountPlan',
lastPage: 'accountHome'
});
};
return (
<ActionButton
isRunning={isRunning}
label="View plans"
onClick={() => openPlanPage()}
brandColor={brandColor}
style={{width: '100%'}}
/>
);
};
const AccountWelcome = () => {
const {member, site} = useContext(AppContext);
const {is_stripe_configured: isStripeConfigured} = site;
if (!isStripeConfigured || hasOnlyFreePlan({site})) {
return null;
}
const subscription = getMemberSubscription({member});
const isComplimentary = isComplimentaryMember({member});
if (isComplimentary && !subscription) {
return null;
}
if (subscription) {
const currentPeriodEnd = subscription?.current_period_end;
if (isComplimentary && getCompExpiry({member})) {
const expiryDate = getCompExpiry({member});
const expiryAt = getDateString(expiryDate);
return (
<div className='gh-portal-section'>
<p className='gh-portal-text-center gh-portal-free-ctatext'>Your subscription will expire on {expiryAt}</p>
</div>
);
}
if (subscription?.cancel_at_period_end) {
return null;
}
if (subscriptionHasFreeTrial({sub: subscription})) {
const trialEnd = getDateString(subscription.trial_end_at);
return (
<div className='gh-portal-section'>
<p className='gh-portal-text-center gh-portal-free-ctatext'>Your subscription will start on {trialEnd}</p>
</div>
);
}
return (
<div className='gh-portal-section'>
<p className='gh-portal-text-center gh-portal-free-ctatext'>Your subscription will renew on {getDateString(currentPeriodEnd)}</p>
</div>
);
}
return (
<div className='gh-portal-section'>
<p className='gh-portal-text-center gh-portal-free-ctatext'>You currently have a free membership, upgrade to a paid subscription for full access.</p>
<SubscribeButton />
</div>
);
};
const ContinueSubscriptionButton = () => {
const {member, onAction, action, brandColor} = useContext(AppContext);
const subscription = getMemberSubscription({member});
if (!subscription) {
return null;
}
// To show only continue button and not cancellation
if (!subscription.cancel_at_period_end) {
return null;
}
const label = subscription.cancel_at_period_end ? 'Continue subscription' : 'Cancel subscription';
const isRunning = ['cancelSubscription:running'].includes(action);
const disabled = (isRunning) ? true : false;
const isPrimary = !!subscription.cancel_at_period_end;
const CancelNotice = () => {
if (!subscription.cancel_at_period_end) {
return null;
}
const currentPeriodEnd = subscription.current_period_end;
return (
<p className='gh-portal-text-center gh-portal-free-ctatext'>Your subscription will expire on {getDateString(currentPeriodEnd)}</p>
);
};
return (
<div className='gh-portal-cancelcontinue-container'>
<CancelNotice />
<ActionButton
onClick={(e) => {
onAction('continueSubscription', {
subscriptionId: subscription.id
});
}}
isRunning={isRunning}
disabled={disabled}
isPrimary={isPrimary}
brandColor={brandColor}
label={label}
style={{
width: '100%'
}}
/>
</div>
);
};
const AccountMain = () => {
return (
<div className='gh-portal-content gh-portal-account-main'>
<CloseButton />
<UserHeader />
<section className='gh-portal-account-data'>
<AccountWelcome />
<ContinueSubscriptionButton />
<AccountActions />
</section>
</div>
);
};
export default class AccountHomePage extends React.Component {
static contextType = AppContext;
componentDidMount() {
const {member} = this.context;
if (!member) {
this.context.onAction('switchPage', {
page: 'signin'
});
}
}
handleSignout(e) {
e.preventDefault();
this.context.onAction('signout');
}
render() {
const {member, site} = this.context;
const supportAddress = getSupportAddress({site});
if (!member) {
return null;
}
return (
<div className='gh-portal-account-wrapper'>
<AccountMain />
<AccountFooter
onClose={() => this.context.onAction('closePopup')}
handleSignout={e => this.handleSignout(e)}
supportAddress={supportAddress}
/>
</div>
);
}
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import {render, fireEvent} from '../../utils/test-utils';
import AccountHomePage from './AccountHomePage';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<AccountHomePage />
);
const logoutBtn = utils.queryByRole('button', {name: 'logout'});
return {
logoutBtn,
mockOnActionFn,
...utils
};
};
describe('Account Home Page', () => {
test('renders', () => {
const {logoutBtn} = setup();
expect(logoutBtn).toBeInTheDocument();
});
test('can call signout', () => {
const {mockOnActionFn, logoutBtn} = setup();
fireEvent.click(logoutBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('signout');
});
});

View File

@ -0,0 +1,473 @@
import {useContext, useState} from 'react';
import AppContext from '../../AppContext';
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
import BackButton from '../common/BackButton';
import {MultipleProductsPlansSection} from '../common/PlansSection';
import {getDateString} from '../../utils/date-time';
import {allowCompMemberUpgrade, formatNumber, getAvailablePrices, getFilteredPrices, getMemberActivePrice, getMemberSubscription, getPriceFromSubscription, getProductFromPrice, getSubscriptionFromId, getUpgradeProducts, hasMultipleProductsFeature, isComplimentaryMember, isPaidMember} from '../../utils/helpers';
export const AccountPlanPageStyles = `
.account-plan.full-size .gh-portal-main-title {
font-size: 3.2rem;
margin-top: 44px;
}
.gh-portal-accountplans-main {
margin-top: 24px;
margin-bottom: 0;
}
.gh-portal-expire-container {
margin: 32px 0 0;
}
.gh-portal-cancellation-form p {
margin-bottom: 12px;
}
.gh-portal-cancellation-form .gh-portal-input-section {
margin-bottom: 20px;
}
.gh-portal-cancellation-form .gh-portal-input {
resize: none;
width: 100%;
height: 62px;
padding: 6px 12px;
}
`;
const React = require('react');
function getConfirmationPageTitle({confirmationType}) {
if (confirmationType === 'changePlan') {
return 'Confirm subscription';
} else if (confirmationType === 'cancel') {
return 'Cancel subscription';
} else if (confirmationType === 'subscribe') {
return 'Subscribe';
}
}
const Header = ({onBack, showConfirmation, confirmationType}) => {
const {member} = useContext(AppContext);
let title = isPaidMember({member}) ? 'Change plan' : 'Choose a plan';
if (showConfirmation) {
title = getConfirmationPageTitle({confirmationType});
}
return (
<header className='gh-portal-detail-header'>
<h3 className='gh-portal-main-title'>{title}</h3>
</header>
);
};
const CancelSubscriptionButton = ({member, onCancelSubscription, action, brandColor}) => {
const {site} = useContext(AppContext);
if (!member.paid) {
return null;
}
const subscription = getMemberSubscription({member});
if (!subscription) {
return null;
}
// Hide the button if subscription is due cancellation
if (subscription.cancel_at_period_end) {
return null;
}
const label = 'Cancel subscription';
const isRunning = ['cancelSubscription:running'].includes(action);
const disabled = (isRunning) ? true : false;
const isPrimary = !!subscription.cancel_at_period_end;
const isDestructive = !subscription.cancelAtPeriodEnd;
return (
<div className="gh-portal-expire-container">
<ActionButton
onClick={(e) => {
onCancelSubscription({
subscriptionId: subscription.id,
cancelAtPeriodEnd: true
});
}}
isRunning={isRunning}
disabled={disabled}
isPrimary={isPrimary}
isDestructive={isDestructive}
classes={hasMultipleProductsFeature({site}) ? 'gh-portal-btn-text mt2 mb4' : ''}
brandColor={brandColor}
label={label}
style={{
width: '100%'
}}
/>
</div>
);
};
// For confirmation flows
const PlanConfirmationSection = ({plan, type, onConfirm}) => {
const {site, action, member, brandColor} = useContext(AppContext);
const [reason, setReason] = useState('');
const subscription = getMemberSubscription({member});
const isRunning = ['updateSubscription:running', 'checkoutPlan:running', 'cancelSubscription:running'].includes(action);
const label = 'Confirm';
let planStartDate = getDateString(subscription.current_period_end);
const currentActivePlan = getMemberActivePrice({member});
if (currentActivePlan.id !== plan.id) {
planStartDate = 'today';
}
const priceString = formatNumber(plan.price);
const planStartMessage = `${plan.currency_symbol}${priceString}/${plan.interval} Starting ${planStartDate}`;
const product = getProductFromPrice({site, priceId: plan?.id});
const priceLabel = hasMultipleProductsFeature({site}) ? product?.name : 'Price';
if (type === 'changePlan') {
return (
<div className='gh-portal-logged-out-form-container'>
<div className='gh-portal-list mb6'>
<section>
<div className='gh-portal-list-detail'>
<h3>Account</h3>
<p>{member.email}</p>
</div>
</section>
<section>
<div className='gh-portal-list-detail'>
<h3>{priceLabel}</h3>
<p>{planStartMessage}</p>
</div>
</section>
</div>
<ActionButton
onClick={e => onConfirm(e, plan)}
isRunning={isRunning}
isPrimary={true}
brandColor={brandColor}
label={label}
style={{
width: '100%',
height: '40px'
}}
/>
</div>
);
} else {
return (
<div className="gh-portal-logged-out-form-container gh-portal-cancellation-form">
<p>If you cancel your subscription now, you will continue to have access until <strong>{getDateString(subscription.current_period_end)}</strong>.</p>
<section className='gh-portal-input-section'>
<div className='gh-portal-input-labelcontainer'>
<label className='gh-portal-input-label'>Cancellation reason</label>
</div>
<textarea
className='gh-portal-input'
key='cancellation_reason'
label='Cancellation reason'
type='text'
name='cancellation_reason'
placeholder=''
value={reason}
onChange={e => setReason(e.target.value)}
rows="2"
maxLength="500"
/>
</section>
<ActionButton
onClick={e => onConfirm(e, reason)}
isRunning={isRunning}
isPrimary={true}
brandColor={brandColor}
label={label + ' cancellation'}
style={{
width: '100%',
height: '40px'
}}
/>
</div>
);
}
};
// For paid members
const ChangePlanSection = ({plans, selectedPlan, onPlanSelect, onCancelSubscription}) => {
const {member, action, brandColor} = useContext(AppContext);
return (
<section>
<div className='gh-portal-section gh-portal-accountplans-main'>
<PlansOrProductSection
showLabel={false}
plans={plans}
selectedPlan={selectedPlan}
onPlanSelect={onPlanSelect}
changePlan={true}
/>
</div>
<CancelSubscriptionButton {...{member, onCancelSubscription, action, brandColor}} />
</section>
);
};
function PlansOrProductSection({showLabel, plans, selectedPlan, onPlanSelect, onPlanCheckout, changePlan = false}) {
const {site, member} = useContext(AppContext);
const products = getUpgradeProducts({site, member});
return (
<MultipleProductsPlansSection
products={products}
selectedPlan={selectedPlan}
changePlan={changePlan}
onPlanSelect={onPlanSelect}
onPlanCheckout={onPlanCheckout}
/>
);
}
// For free members
const UpgradePlanSection = ({
plans, selectedPlan, onPlanSelect, onPlanCheckout
}) => {
// const {action, brandColor} = useContext(AppContext);
// const isRunning = ['checkoutPlan:running'].includes(action);
let singlePlanClass = '';
if (plans.length === 1) {
singlePlanClass = 'singleplan';
}
return (
<section>
<div className={`gh-portal-section gh-portal-accountplans-main ${singlePlanClass}`}>
<PlansOrProductSection
showLabel={false}
plans={plans}
selectedPlan={selectedPlan}
onPlanSelect={onPlanSelect}
onPlanCheckout={onPlanCheckout}
/>
</div>
{/* <ActionButton
onClick={e => onPlanCheckout(e)}
isRunning={isRunning}
isPrimary={true}
brandColor={brandColor}
label={'Continue'}
style={{height: '40px', width: '100%', marginTop: '24px'}}
/> */}
</section>
);
};
const PlansContainer = ({
plans, selectedPlan, confirmationPlan, confirmationType, showConfirmation = false,
onPlanSelect, onPlanCheckout, onConfirm, onCancelSubscription
}) => {
const {member} = useContext(AppContext);
// Plan upgrade flow for free member
const allowUpgrade = allowCompMemberUpgrade({member}) && isComplimentaryMember({member});
if (!isPaidMember({member}) || allowUpgrade) {
return (
<UpgradePlanSection
{...{plans, selectedPlan, onPlanSelect, onPlanCheckout}}
/>
);
}
// Plan change flow for a paid member
if (!showConfirmation) {
return (
<ChangePlanSection
{...{plans, selectedPlan,
onCancelSubscription, onPlanSelect}}
/>
);
}
// Plan confirmation flow for cancel/update flows
return (
<PlanConfirmationSection
{...{plan: confirmationPlan, type: confirmationType, onConfirm}}
/>
);
};
export default class AccountPlanPage extends React.Component {
static contextType = AppContext;
constructor(props, context) {
super(props, context);
this.state = this.getInitialState();
}
componentDidMount() {
const {member} = this.context;
if (!member) {
this.context.onAction('switchPage', {
page: 'signin'
});
}
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
getInitialState() {
const {member, site} = this.context;
this.prices = getAvailablePrices({site});
let activePrice = getMemberActivePrice({member});
if (activePrice) {
this.prices = getFilteredPrices({prices: this.prices, currency: activePrice.currency});
}
let selectedPrice = activePrice ? this.prices.find((d) => {
return (d.id === activePrice.id);
}) : null;
// Select first plan as default for free member
if (!isPaidMember({member}) && this.prices.length > 0) {
selectedPrice = this.prices[0];
}
const selectedPriceId = selectedPrice ? selectedPrice.id : null;
return {
selectedPlan: selectedPriceId
};
}
handleSignout(e) {
e.preventDefault();
this.context.onAction('signout');
}
onBack(e) {
if (this.state.showConfirmation) {
this.cancelConfirmPage();
} else {
this.context.onAction('back');
}
}
cancelConfirmPage() {
this.setState({
showConfirmation: false,
confirmationPlan: null,
confirmationType: null
});
}
onPlanCheckout(e, priceId) {
const {onAction, member} = this.context;
let {confirmationPlan, selectedPlan} = this.state;
if (priceId) {
selectedPlan = priceId;
}
const restrictCheckout = allowCompMemberUpgrade({member}) ? !isComplimentaryMember({member}) : true;
if (isPaidMember({member}) && restrictCheckout) {
const subscription = getMemberSubscription({member});
const subscriptionId = subscription ? subscription.id : '';
if (subscriptionId) {
onAction('updateSubscription', {plan: confirmationPlan.name, planId: confirmationPlan.id, subscriptionId, cancelAtPeriodEnd: false});
}
} else {
onAction('checkoutPlan', {plan: selectedPlan});
}
}
onPlanSelect = (e, priceId) => {
e?.preventDefault();
const {member} = this.context;
const allowCompMember = allowCompMemberUpgrade({member}) ? isComplimentaryMember({member}) : false;
// Work as checkboxes for free member plan selection and button for paid members
if (!isPaidMember({member}) || allowCompMember) {
// Hack: React checkbox gets out of sync with dom state with instant update
this.timeoutId = setTimeout(() => {
this.setState(() => {
return {
selectedPlan: priceId
};
});
}, 5);
} else {
const confirmationPrice = this.prices.find(d => d.id === priceId);
const activePlan = this.getActivePriceId({member});
const confirmationType = activePlan ? 'changePlan' : 'subscribe';
if (priceId !== this.state.selectedPlan) {
this.setState({
confirmationPlan: confirmationPrice,
confirmationType,
showConfirmation: true
});
}
}
};
onCancelSubscription({subscriptionId, cancelAtPeriodEnd}) {
const {member} = this.context;
const subscription = getSubscriptionFromId({subscriptionId, member});
const subscriptionPlan = getPriceFromSubscription({subscription});
this.setState({
showConfirmation: true,
confirmationPlan: subscriptionPlan,
confirmationType: 'cancel'
});
}
onCancelSubscriptionConfirmation(reason) {
const {member} = this.context;
const subscription = getMemberSubscription({member});
if (!subscription) {
return null;
}
this.context.onAction('cancelSubscription', {
subscriptionId: subscription.id,
cancelAtPeriodEnd: true,
cancellationReason: reason
});
}
getActivePriceId({member}) {
const activePrice = getMemberActivePrice({member});
if (activePrice) {
return activePrice.id;
}
return null;
}
onConfirm(e, data) {
const {confirmationType} = this.state;
if (confirmationType === 'cancel') {
return this.onCancelSubscriptionConfirmation(data);
} else if (['changePlan', 'subscribe'].includes(confirmationType)) {
return this.onPlanCheckout();
}
}
render() {
const plans = this.prices;
const {selectedPlan, showConfirmation, confirmationPlan, confirmationType} = this.state;
const {lastPage} = this.context;
return (
<>
<div className='gh-portal-content'>
<BackButton onClick={e => this.onBack(e)} hidden={!lastPage && !showConfirmation} />
<CloseButton />
<Header
onBack={e => this.onBack(e)}
confirmationType={confirmationType}
showConfirmation={showConfirmation}
/>
<PlansContainer
{...{plans, selectedPlan, showConfirmation, confirmationPlan, confirmationType}}
onConfirm={(...args) => this.onConfirm(...args)}
onCancelSubscription = {data => this.onCancelSubscription(data)}
onPlanSelect = {this.onPlanSelect}
onPlanCheckout = {(e, name) => this.onPlanCheckout(e, name)}
/>
</div>
</>
);
}
}

View File

@ -0,0 +1,80 @@
import React from 'react';
import {generateAccountPlanFixture, getSiteData, getProductsData} from '../../utils/fixtures-generator';
import {render, fireEvent} from '../../utils/test-utils';
import AccountPlanPage from './AccountPlanPage';
const setup = (overrides) => {
const {mockOnActionFn, context, ...utils} = render(
<AccountPlanPage />,
{
overrideContext: {
...overrides
}
}
);
const monthlyCheckboxEl = utils.queryByRole('button', {name: 'Monthly'});
const yearlyCheckboxEl = utils.queryByRole('button', {name: 'Yearly'});
const continueBtn = utils.queryByRole('button', {name: 'Continue'});
const chooseBtns = utils.queryAllByRole('button', {name: 'Choose'});
return {
monthlyCheckboxEl,
yearlyCheckboxEl,
continueBtn,
chooseBtns,
mockOnActionFn,
context,
...utils
};
};
const customSetup = (overrides) => {
const {mockOnActionFn, context, ...utils} = render(
<AccountPlanPage />,
{
overrideContext: {
...overrides
}
}
);
return {
mockOnActionFn,
context,
...utils
};
};
describe('Account Plan Page', () => {
test('renders', () => {
const {monthlyCheckboxEl, yearlyCheckboxEl, queryAllByRole} = setup();
const continueBtn = queryAllByRole('button', {name: 'Continue'});
expect(monthlyCheckboxEl).toBeInTheDocument();
expect(yearlyCheckboxEl).toBeInTheDocument();
expect(continueBtn).toHaveLength(1);
});
test('can choose plan and continue', async () => {
const siteData = getSiteData({
products: getProductsData({numOfProducts: 1})
});
const {mockOnActionFn, monthlyCheckboxEl, yearlyCheckboxEl, queryAllByRole} = setup({site: siteData});
const continueBtn = queryAllByRole('button', {name: 'Continue'});
fireEvent.click(monthlyCheckboxEl);
expect(monthlyCheckboxEl.className).toEqual('gh-portal-btn active');
fireEvent.click(yearlyCheckboxEl);
expect(yearlyCheckboxEl.className).toEqual('gh-portal-btn active');
fireEvent.click(continueBtn[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('checkoutPlan', {plan: siteData.products[0].yearlyPrice.id});
});
test('can cancel subscription for member on hidden tier', async () => {
const overrides = generateAccountPlanFixture();
const {queryByRole} = customSetup(overrides);
const cancelButton = queryByRole('button', {name: 'Cancel subscription'});
expect(cancelButton).toBeInTheDocument();
fireEvent.click(cancelButton);
const confirmCancelButton = queryByRole('button', {name: 'Confirm cancellation'});
expect(confirmCancelButton).toBeInTheDocument();
});
});

View File

@ -0,0 +1,197 @@
import AppContext from '../../AppContext';
import MemberAvatar from '../common/MemberGravatar';
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
import BackButton from '../common/BackButton';
import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
const React = require('react');
export default class AccountProfilePage extends React.Component {
static contextType = AppContext;
constructor(props, context) {
super(props, context);
const {name = '', email = ''} = context.member || {};
this.state = {
name,
email
};
}
componentDidMount() {
const {member} = this.context;
if (!member) {
this.context.onAction('switchPage', {
page: 'signin'
});
}
}
handleSignout(e) {
e.preventDefault();
this.context.onAction('signout');
}
onBack(e) {
this.context.onAction('back');
}
onProfileSave(e) {
e.preventDefault();
this.setState((state) => {
return {
errors: ValidateInputForm({fields: this.getInputFields({state})})
};
}, () => {
const {email, name, errors} = this.state;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
this.context.onAction('clearPopupNotification');
this.context.onAction('updateProfile', {email, name});
}
});
}
renderSaveButton() {
const isRunning = (this.context.action === 'updateProfile:running');
let label = 'Save';
if (this.context.action === 'updateProfile:failed') {
label = 'Retry';
}
const disabled = isRunning ? true : false;
return (
<ActionButton
isRunning={isRunning}
onClick={e => this.onProfileSave(e)}
disabled={disabled}
brandColor={this.context.brandColor}
label={label}
style={{width: '100%'}}
/>
);
}
renderDeleteAccountButton() {
return (
<div style={{cursor: 'pointer', color: 'red'}} role='button'>Delete account</div>
);
}
renderAccountFooter() {
return (
<footer className='gh-portal-action-footer'>
{this.renderSaveButton()}
</footer>
);
}
renderHeader() {
return (
<header className='gh-portal-detail-header'>
<BackButton brandColor={this.context.brandColor} hidden={!this.context.lastPage} onClick={e => this.onBack(e)} />
<h3 className='gh-portal-main-title'>Account settings</h3>
</header>
);
}
renderUserAvatar() {
const avatarImg = (this.context.member && this.context.member.avatar_image);
const avatarContainerStyle = {
position: 'relative',
display: 'flex',
width: '64px',
height: '64px',
marginBottom: '6px',
borderRadius: '100%',
boxShadow: '0 0 0 3px #fff',
border: '1px solid gray',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center'
};
return (
<div style={avatarContainerStyle}>
<MemberAvatar gravatar={avatarImg} style={{userIcon: {color: 'black', width: '56px', height: '56px'}}} />
</div>
);
}
handleInputChange(e, field) {
const fieldName = field.name;
this.setState({
[fieldName]: e.target.value
});
}
getInputFields({state, fieldNames}) {
const errors = state.errors || {};
const fields = [
{
type: 'text',
value: state.name,
placeholder: 'Jamie Larson',
label: 'Name',
name: 'name',
required: true,
errorMessage: errors.name || ''
},
{
type: 'email',
value: state.email,
placeholder: 'jamie@example.com',
label: 'Email',
name: 'email',
required: true,
errorMessage: errors.email || ''
}
];
if (fieldNames && fieldNames.length > 0) {
return fields.filter((f) => {
return fieldNames.includes(f.name);
});
}
return fields;
}
onKeyDown(e) {
// Handles submit on Enter press
if (e.keyCode === 13){
this.onProfileSave(e);
}
}
renderProfileData() {
return (
<div className='gh-portal-section'>
<InputForm
fields={this.getInputFields({state: this.state})}
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={(e, field) => this.onKeyDown(e, field)}
/>
</div>
);
}
render() {
const {member} = this.context;
if (!member) {
return null;
}
return (
<>
<div className='gh-portal-content with-footer'>
<CloseButton />
{this.renderHeader()}
<div className='gh-portal-section'>
{this.renderProfileData()}
</div>
</div>
{this.renderAccountFooter()}
</>
);
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import {render, fireEvent} from '../../utils/test-utils';
import AccountProfilePage from './AccountProfilePage';
const setup = (overrides) => {
const {mockOnActionFn, context, ...utils} = render(
<AccountProfilePage />
);
const emailInputEl = utils.getByLabelText(/email/i);
const nameInputEl = utils.getByLabelText(/name/i);
const saveBtn = utils.queryByRole('button', {name: 'Save'});
return {
emailInputEl,
nameInputEl,
saveBtn,
mockOnActionFn,
context,
...utils
};
};
describe('Account Profile Page', () => {
test('renders', () => {
const {emailInputEl, nameInputEl, saveBtn} = setup();
expect(emailInputEl).toBeInTheDocument();
expect(nameInputEl).toBeInTheDocument();
expect(saveBtn).toBeInTheDocument();
});
test('can call save', () => {
const {mockOnActionFn, saveBtn, context} = setup();
fireEvent.click(saveBtn);
const {email, name} = context.member;
expect(mockOnActionFn).toHaveBeenCalledWith('updateProfile', {email, name});
});
});

View File

@ -0,0 +1,13 @@
import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg';
const React = require('react');
export default class LoadingPage extends React.Component {
render() {
return (
<div style={{display: 'flex', flexDirection: 'column', color: '#313131'}}>
<div style={{paddingLeft: '16px', paddingRight: '16px', paddingTop: '12px', height: '50px'}}>
<LoaderIcon className={'gh-portal-loadingicon dark'} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,87 @@
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
import AppContext from '../../AppContext';
import {ReactComponent as EnvelopeIcon} from '../../images/icons/envelope.svg';
const React = require('react');
export const MagicLinkStyles = `
.gh-portal-icon-envelope {
width: 44px;
margin: 12px 0 10px;
}
.gh-portal-inbox-notification {
display: flex;
flex-direction: column;
align-items: center;
}
.gh-portal-inbox-notification p {
text-align: center;
margin-bottom: 30px;
}
`;
export default class MagicLinkPage extends React.Component {
static contextType = AppContext;
renderFormHeader() {
let popupTitle = `We've sent you a login link!`;
let popupDescription = `If the email doesn't arrive in 3 minutes, be sure to check your spam folder!`;
if (this.context.lastPage === 'signup') {
popupTitle = `Now check your email!`;
popupDescription = `To complete signup, click the confirmation link in your inbox. If it doesnt arrive within 3 minutes, check your spam folder!`;
}
return (
<section className='gh-portal-inbox-notification'>
<header className='gh-portal-header'>
<EnvelopeIcon className='gh-portal-icon gh-portal-icon-envelope' />
<h2 className='gh-portal-main-title'>{popupTitle}</h2>
</header>
<p>{popupDescription}</p>
</section>
);
}
renderLoginMessage() {
return (
<>
<div
style={{color: '#1d1d1d', fontWeight: 'bold', cursor: 'pointer'}}
onClick={() => this.context.onAction('switchPage', {page: 'signin'})}
>
Back to Log in
</div>
</>
);
}
handleClose(e) {
this.context.onAction('closePopup');
}
renderCloseButton() {
const label = 'Close';
return (
<ActionButton
style={{width: '100%'}}
onClick={e => this.handleClose(e)}
brandColor={this.context.brandColor}
label={label}
/>
);
}
render() {
return (
<div className='gh-portal-content'>
<CloseButton />
{this.renderFormHeader()}
{this.renderCloseButton()}
</div>
);
}
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import {render, fireEvent} from '../../utils/test-utils';
import MagicLinkPage from './MagicLinkPage';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<MagicLinkPage />
);
const inboxText = utils.getByText(/we've sent you a login link/i);
const closeBtn = utils.queryByRole('button', {name: 'Close'});
return {
inboxText,
closeBtn,
mockOnActionFn,
...utils
};
};
describe('MagicLinkPage', () => {
test('renders', () => {
const {inboxText, closeBtn} = setup();
expect(inboxText).toBeInTheDocument();
expect(closeBtn).toBeInTheDocument();
});
test('calls on action with close popup', () => {
const {closeBtn, mockOnActionFn} = setup();
fireEvent.click(closeBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('closePopup');
});
});

View File

@ -0,0 +1,134 @@
import AppContext from '../../AppContext';
import {useContext, useState} from 'react';
import Switch from '../common/Switch';
import {getSiteNewsletters} from '../../utils/helpers';
import ActionButton from '../common/ActionButton';
import {ReactComponent as LockIcon} from '../../images/icons/lock.svg';
const React = require('react');
function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribedNewsletters}) {
const isChecked = subscribedNewsletters.some((d) => {
return d.id === newsletter?.id;
});
if (newsletter.paid) {
return (
<section className='gh-portal-list-toggle-wrapper'>
<div className='gh-portal-list-detail gh-portal-list-big'>
<h3>{newsletter.name}</h3>
<p>{newsletter.description}</p>
</div>
<div class="gh-portal-lock-icon-container">
<LockIcon className='gh-portal-lock-icon' alt='' title="Unlock access to all newsletters by becoming a paid subscriber." />
</div>
</section>
);
}
return (
<section className='gh-portal-list-toggle-wrapper'>
<div className='gh-portal-list-detail gh-portal-list-big'>
<h3>{newsletter.name}</h3>
<p>{newsletter.description}</p>
</div>
<div>
<Switch id={newsletter.id} onToggle={(e, checked) => {
let updatedNewsletters = [];
if (!checked) {
updatedNewsletters = subscribedNewsletters.filter((d) => {
return d.id !== newsletter.id;
});
} else {
updatedNewsletters = subscribedNewsletters.filter((d) => {
return d.id !== newsletter.id;
}).concat(newsletter);
}
setSubscribedNewsletters(updatedNewsletters);
}} checked={isChecked} />
</div>
</section>
);
}
function NewsletterPrefs({subscribedNewsletters, setSubscribedNewsletters}) {
const {site} = useContext(AppContext);
const newsletters = getSiteNewsletters({site});
return newsletters.map((newsletter) => {
return (
<NewsletterPrefSection
key={newsletter?.id}
newsletter={newsletter}
subscribedNewsletters={subscribedNewsletters}
setSubscribedNewsletters={setSubscribedNewsletters}
/>
);
});
}
export default function NewsletterSelectionPage({pageData, onBack}) {
const {brandColor, site, onAction, action} = useContext(AppContext);
const siteNewsletters = getSiteNewsletters({site});
const defaultNewsletters = siteNewsletters.filter((d) => {
return d.subscribe_on_signup;
});
// const tier = getProductFromPrice({site, priceId: pageData.plan});
// const tierName = tier?.name;
let isRunning = false;
if (action === 'signup:running') {
isRunning = true;
}
let label = 'Continue';
let retry = false;
if (action === 'signup:failed') {
label = 'Retry';
retry = true;
}
const disabled = (action === 'signup:running') ? true : false;
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultNewsletters);
return (
<div className='gh-portal-content with-footer gh-portal-newsletter-selection'>
<p className="gh-portal-text-center gh-portal-text-large">Choose your newsletters</p>
<div className='gh-portal-section'>
<div className='gh-portal-list'>
<NewsletterPrefs
subscribedNewsletters={subscribedNewsletters}
setSubscribedNewsletters={setSubscribedNewsletters}
/>
</div>
</div>
<footer className='gh-portal-action-footer'>
<div style={{width: '100%'}}>
<div style={{marginBottom: '20px'}}>
<ActionButton
isRunning={isRunning}
retry={retry}
disabled={disabled}
onClick={(e) => {
let newsletters = subscribedNewsletters.map((d) => {
return {
id: d.id
};
});
const {name, email, plan, offerId} = pageData;
onAction('signup', {name, email, plan, newsletters, offerId});
}}
brandColor={brandColor}
label={label}
style={{width: '100%'}}
/>
</div>
<div>
<button
className='gh-portal-btn gh-portal-btn-link gh-portal-btn-different-plan'
onClick = {() => {
onBack();
}}>
<span>Choose a different plan</span>
</button>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,586 @@
import ActionButton from '../common/ActionButton';
import AppContext from '../../AppContext';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
import CloseButton from '../common/CloseButton';
import InputForm from '../common/InputForm';
import {getCurrencySymbol, getProductFromId, hasMultipleProductsFeature, isSameCurrency, formatNumber, hasMultipleNewsletters} from '../../utils/helpers';
import {ValidateInputForm} from '../../utils/form';
import NewsletterSelectionPage from './NewsletterSelectionPage';
const React = require('react');
export const OfferPageStyles = ({site}) => {
return `
.gh-portal-offer {
padding-bottom: 0;
overflow: unset;
max-height: unset;
}
.gh-portal-offer-container {
display: flex;
flex-direction: column;
}
.gh-portal-plans-container.offer {
justify-content: space-between;
border-color: var(--grey12);
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: 12px 16px;
font-size: 1.3rem;
}
.gh-portal-offer-bar {
position: relative;
padding: 26px 28px 28px;
margin-bottom: 24px;
/*border: 1px dashed var(--brandcolor);*/
background-image: url("data:image/svg+xml,%3csvg width='100%25' height='99.9%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' stroke='%23C3C3C3' stroke-width='3' stroke-dasharray='3%2c 9' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
border-radius: 6px;
}
.gh-portal-offer-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.gh-portal-offer-title h4 {
font-size: 1.8rem;
margin: 0 110px 0 0;
width: 100%;
}
.gh-portal-offer-title h4.placeholder {
opacity: 0.4;
}
.gh-portal-offer-bar .gh-portal-discount-label {
position: absolute;
top: 23px;
right: 25px;
}
.gh-portal-offer-bar p {
padding-bottom: 0;
margin: 12px 0 0;
}
.gh-portal-offer-title h4 + p {
margin: 12px 0 0;
}
.gh-portal-offer-details .gh-portal-plan-name,
.gh-portal-offer-details p {
margin-right: 8px;
}
.gh-portal-offer .footnote {
font-size: 1.35rem;
color: var(--grey8);
margin: 4px 0 0;
}
.offer .gh-portal-product-card {
max-width: unset;
min-height: 0;
}
.offer .gh-portal-product-card .gh-portal-product-card-pricecontainer:not(.offer-type-trial) {
margin-top: 0px;
}
.offer .gh-portal-product-card-header {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.gh-portal-offer-oldprice {
display: flex;
position: relative;
font-size: 1.8rem;
font-weight: 300;
color: var(--grey8);
line-height: 1;
white-space: nowrap;
margin: 16px 0 4px;
}
.gh-portal-offer-oldprice:after {
position: absolute;
display: block;
content: "";
left: 0;
top: 50%;
right: 0;
height: 1px;
background: var(--grey8);
}
.gh-portal-offer-details p {
margin-bottom: 12px;
}
.offer .after-trial-amount {
margin-bottom: 0;
}
.offer .trial-duration {
margin-top: 16px;
}
.gh-portal-cancel {
white-space: nowrap;
}
`;
};
export default class OfferPage extends React.Component {
static contextType = AppContext;
constructor(props, context) {
super(props, context);
this.state = {
name: context?.member?.name || '',
email: context?.member?.email || '',
plan: 'free',
showNewsletterSelection: false
};
}
getInputFields({state, fieldNames}) {
const {portal_name: portalName} = this.context.site;
const {member} = this.context;
const errors = state.errors || {};
const fields = [
{
type: 'email',
value: member?.email || state.email,
placeholder: 'jamie@example.com',
label: 'Email',
name: 'email',
disabled: !!member,
required: true,
tabindex: 2,
errorMessage: errors.email || ''
}
];
/** Show Name field if portal option is set*/
let showNameField = !!portalName;
/** Hide name field for logged in member if empty */
if (!!member && !member?.name) {
showNameField = false;
}
if (showNameField) {
fields.unshift({
type: 'text',
value: member?.name || state.name,
placeholder: 'Jamie Larson',
label: 'Name',
name: 'name',
disabled: !!member,
required: true,
tabindex: 1,
errorMessage: errors.name || ''
});
}
fields[0].autoFocus = true;
if (fieldNames && fieldNames.length > 0) {
return fields.filter((f) => {
return fieldNames.includes(f.name);
});
}
return fields;
}
onKeyDown(e) {
// Handles submit on Enter press
if (e.keyCode === 13){
this.handleSignup(e);
}
}
handleSignup(e) {
e.preventDefault();
const {pageData: offer, site} = this.context;
if (!offer) {
return null;
}
const product = getProductFromId({site, productId: offer.tier.id});
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
this.setState((state) => {
return {
errors: ValidateInputForm({fields: this.getInputFields({state})})
};
}, () => {
const {onAction} = this.context;
const {name, email, errors} = this.state;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
const signupData = {
name, email, plan: price?.id,
offerId: offer?.id
};
if (hasMultipleNewsletters({site})) {
this.setState({
showNewsletterSelection: true,
pageData: signupData,
errors: {}
});
} else {
onAction('signup', signupData);
this.setState({
errors: {}
});
}
}
});
}
handleInputChange(e, field) {
const fieldName = field.name;
const value = e.target.value;
this.setState({
[fieldName]: value
});
}
renderSiteLogo() {
const {site} = this.context;
const siteLogo = site.icon;
const logoStyle = {};
if (siteLogo) {
logoStyle.backgroundImage = `url(${siteLogo})`;
return (
<img className='gh-portal-signup-logo' src={siteLogo} alt={site.title} />
);
}
return null;
}
renderFormHeader() {
const {site} = this.context;
const siteTitle = site.title || '';
return (
<header className='gh-portal-signup-header'>
{this.renderSiteLogo()}
<h2 className="gh-portal-main-title">{siteTitle}</h2>
</header>
);
}
renderForm() {
const fields = this.getInputFields({state: this.state});
if (this.state.showNewsletterSelection) {
return (
<NewsletterSelectionPage
pageData={this.state.pageData}
onBack={() => {
this.setState({
showNewsletterSelection: false
});
}}
/>
);
}
return (
<section>
<div className='gh-portal-section'>
<InputForm
fields={fields}
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={e => this.onKeyDown(e)}
/>
</div>
</section>
);
}
renderSubmitButton() {
const {action, brandColor} = this.context;
const {pageData: offer} = this.context;
let label = 'Continue';
if (offer.type === 'trial') {
label = 'Start ' + offer.amount + '-day free trial';
}
let isRunning = false;
if (action === 'signup:running') {
label = 'Sending...';
isRunning = true;
}
let retry = false;
if (action === 'signup:failed') {
label = 'Retry';
retry = true;
}
const disabled = (action === 'signup:running') ? true : false;
return (
<ActionButton
style={{width: '100%'}}
retry={retry}
onClick={e => this.handleSignup(e)}
disabled={disabled}
brandColor={brandColor}
label={label}
isRunning={isRunning}
tabindex='3'
classes={'sticky bottom'}
/>
);
}
renderLoginMessage() {
const {member} = this.context;
if (member) {
return null;
}
const {brandColor, onAction} = this.context;
return (
<div className='gh-portal-signup-message'>
<div>Already a member?</div>
<button
className='gh-portal-btn gh-portal-btn-link'
style={{color: brandColor}}
onClick={() => onAction('switchPage', {page: 'signin'})}
>
<span>Sign in</span>
</button>
</div>
);
}
renderOfferTag() {
const {pageData: offer} = this.context;
if (offer.amount <= 0) {
return (
<></>
);
}
if (offer.type === 'fixed') {
return (
<h5 className="gh-portal-discount-label">{getCurrencySymbol(offer.currency)}{offer.amount / 100} off</h5>
);
}
if (offer.type === 'trial') {
return (
<h5 className="gh-portal-discount-label">{offer.amount} days free</h5>
);
}
return (
<h5 className="gh-portal-discount-label">{offer.amount}% off</h5>
);
}
renderBenefits({product}) {
const benefits = product.benefits || [];
if (!benefits?.length) {
return;
}
const benefitsUI = benefits.map((benefit, idx) => {
return (
<div className="gh-portal-product-benefit" key={`${benefit.name}-${idx}`}>
<CheckmarkIcon className='gh-portal-benefit-checkmark' />
<div className="gh-portal-benefit-title">{benefit.name}</div>
</div>
);
});
return (
<div className="gh-portal-product-benefits">
{benefitsUI}
</div>
);
}
getOriginalPrice({offer, product}) {
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
const originalAmount = this.renderRoundedPrice(price.amount / 100);
return `${getCurrencySymbol(price.currency)}${originalAmount}/${offer.cadence}`;
}
getUpdatedPrice({offer, product}) {
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
const originalAmount = price.amount;
let updatedAmount;
if (offer.type === 'fixed' && isSameCurrency(offer.currency, price.currency)) {
updatedAmount = ((originalAmount - offer.amount)) / 100;
return updatedAmount > 0 ? updatedAmount : 0;
} else if (offer.type === 'percent') {
updatedAmount = (originalAmount - ((originalAmount * offer.amount) / 100)) / 100;
return updatedAmount;
}
return originalAmount / 100;
}
renderRoundedPrice(price) {
if (price % 1 !== 0) {
const roundedPrice = Math.round(price * 100) / 100;
return Number(roundedPrice).toFixed(2);
}
return price;
}
getOffAmount({offer}) {
if (offer.type === 'fixed') {
return `${getCurrencySymbol(offer.currency)}${offer.amount / 100}`;
} else if (offer.type === 'percent') {
return `${offer.amount}%`;
} else if (offer.type === 'trial') {
return offer.amount;
}
return '';
}
renderOfferMessage({offer, product, price}) {
const discountDuration = offer.duration;
let durationLabel = '';
const originalPrice = this.getOriginalPrice({offer, product});
let renewsLabel = '';
if (discountDuration === 'once') {
durationLabel = `for first ${offer.cadence}`;
renewsLabel = `Renews at ${originalPrice}.`;
} else if (discountDuration === 'forever') {
durationLabel = `forever`;
} else if (discountDuration === 'repeating') {
const durationInMonths = offer.duration_in_months || '';
if (durationInMonths === 1) {
durationLabel = `for first month`;
} else {
durationLabel = `for first ${durationInMonths} months`;
}
renewsLabel = `Renews at ${originalPrice}.`;
}
if (discountDuration === 'trial') {
return (
<p className="footnote">Try free for {offer.amount} days, then {originalPrice}. <span class="gh-portal-cancel">Cancel anytime.</span></p>
);
}
return (
<p className="footnote">{this.getOffAmount({offer})} off {durationLabel}. {renewsLabel}</p>
);
}
renderProductLabel({product, offer}) {
const {site} = this.context;
if (hasMultipleProductsFeature({site})) {
return (
<h4 className="gh-portal-plan-name">{product.name} - {(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
);
}
return (
<h4 className="gh-portal-plan-name">{(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
);
}
renderUpdatedTierPrice({offer, currencyClass, updatedPrice, price}) {
if (offer.type === 'trial') {
return (
<div className="gh-portal-product-card-pricecontainer offer-type-trial">
<div className="gh-portal-product-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
</div>
</div>
);
}
return (
<div className="gh-portal-product-card-pricecontainer">
<div className="gh-portal-product-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
</div>
</div>
);
}
renderOldTierPrice({offer, price}) {
if (offer.type === 'trial') {
return null;
}
return (
<div className="gh-portal-offer-oldprice">{getCurrencySymbol(price.currency)} {formatNumber(price.amount / 100)}</div>
);
}
renderProductCard({product, offer, currencyClass, updatedPrice, price, benefits}) {
if (this.state.showNewsletterSelection) {
return null;
}
return (
<>
<div className='gh-portal-product-card top'>
<div className='gh-portal-product-card-header'>
<h4 className="gh-portal-product-name">{product.name} - {(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
{this.renderOldTierPrice({offer, price})}
{this.renderUpdatedTierPrice({offer, currencyClass, updatedPrice, price})}
{this.renderOfferMessage({offer, product, price})}
</div>
</div>
<div>
<div className='gh-portal-product-card bottom'>
<div className='gh-portal-product-card-detaildata'>
{(product.description ? <div className="gh-portal-product-description">{product.description}</div> : '')}
{(benefits.length ? this.renderBenefits({product}) : '')}
</div>
</div>
<div className='gh-portal-btn-container sticky m32'>
{this.renderSubmitButton()}
</div>
{this.renderLoginMessage()}
</div>
</>
);
}
render() {
const {pageData: offer, site} = this.context;
if (!offer) {
return null;
}
const product = getProductFromId({site, productId: offer.tier.id});
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
const updatedPrice = this.getUpdatedPrice({offer, product});
const benefits = product.benefits || [];
const currencyClass = (getCurrencySymbol(price.currency)).length > 1 ? 'long' : '';
return (
<>
<div className='gh-portal-content gh-portal-offer'>
<CloseButton />
{this.renderFormHeader()}
<div className="gh-portal-offer-bar">
<div className="gh-portal-offer-title">
{(offer.display_title ? <h4>{offer.display_title}</h4> : <h4 className='placeholder'>Black Friday</h4>)}
{this.renderOfferTag()}
</div>
{(offer.display_description ? <p>{offer.display_description}</p> : '')}
</div>
{this.renderForm()}
{this.renderProductCard({product, offer, currencyClass, updatedPrice, price, benefits})}
</div>
</>
);
}
}

View File

@ -0,0 +1,167 @@
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
// import SiteTitleBackButton from '../common/SiteTitleBackButton';
import AppContext from '../../AppContext';
import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
const React = require('react');
export default class SigninPage extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {
email: ''
};
}
componentDidMount() {
const {member} = this.context;
if (member) {
this.context.onAction('switchPage', {
page: 'accountHome'
});
}
}
handleSignin(e) {
e.preventDefault();
this.setState((state) => {
return {
errors: ValidateInputForm({fields: this.getInputFields({state})})
};
}, () => {
const {email, errors} = this.state;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
this.context.onAction('signin', {email});
}
});
}
handleInputChange(e, field) {
const fieldName = field.name;
this.setState({
[fieldName]: e.target.value
});
}
onKeyDown(e) {
// Handles submit on Enter press
if (e.keyCode === 13){
this.handleSignin(e);
}
}
getInputFields({state}) {
const errors = state.errors || {};
const fields = [
{
type: 'email',
value: state.email,
placeholder: 'jamie@example.com',
label: 'Email',
name: 'email',
required: true,
errorMessage: errors.email || '',
autoFocus: true
}
];
return fields;
}
renderSubmitButton() {
const {action} = this.context;
let retry = false;
const isRunning = (action === 'signin:running');
let label = isRunning ? 'Sending login link...' : 'Continue';
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}
brandColor={this.context.brandColor}
label={label}
isRunning={isRunning}
/>
);
}
renderSignupMessage() {
const brandColor = this.context.brandColor;
return (
<div className='gh-portal-signup-message'>
<div>Don't have an account?</div>
<button className='gh-portal-btn gh-portal-btn-link' style={{color: brandColor}} onClick={() => this.context.onAction('switchPage', {page: 'signup'})}><span>Sign up</span></button>
</div>
);
}
renderForm() {
return (
<section>
<div className='gh-portal-section'>
<InputForm
fields={this.getInputFields({state: this.state})}
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={(e, field) => this.onKeyDown(e, field)}
/>
</div>
</section>
);
}
renderSiteLogo() {
const siteLogo = this.context.site.icon;
const logoStyle = {};
if (siteLogo) {
logoStyle.backgroundImage = `url(${siteLogo})`;
return (
<img className='gh-portal-signup-logo' src={siteLogo} alt={this.context.site.title} />
);
}
return null;
}
renderFormHeader() {
// const siteTitle = this.context.site.title || 'Site Title';
return (
<header className='gh-portal-signin-header'>
{this.renderSiteLogo()}
<h1 className="gh-portal-main-title">Sign in</h1>
</header>
);
}
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

@ -0,0 +1,51 @@
import React from 'react';
import {render, fireEvent} from '../../utils/test-utils';
import SigninPage from './SigninPage';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<SigninPage />,
{
overrideContext: {
member: null
}
}
);
const emailInput = utils.getByLabelText(/email/i);
const submitButton = utils.queryByRole('button', {name: 'Continue'});
const signupButton = utils.queryByRole('button', {name: 'Sign up'});
return {
emailInput,
submitButton,
signupButton,
mockOnActionFn,
...utils
};
};
describe('SigninPage', () => {
test('renders', () => {
const {emailInput, submitButton, signupButton} = setup();
expect(emailInput).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
expect(signupButton).toBeInTheDocument();
});
test('can call signin action with email', () => {
const {emailInput, submitButton, mockOnActionFn} = setup();
fireEvent.change(emailInput, {target: {value: 'member@example.com'}});
expect(emailInput).toHaveValue('member@example.com');
fireEvent.click(submitButton);
expect(mockOnActionFn).toHaveBeenCalledWith('signin', {email: 'member@example.com'});
});
test('can call swithPage for signup', () => {
const {signupButton, mockOnActionFn} = setup();
fireEvent.click(signupButton);
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signup'});
});
});

View File

@ -0,0 +1,636 @@
import ActionButton from '../common/ActionButton';
import AppContext from '../../AppContext';
import CloseButton from '../common/CloseButton';
import SiteTitleBackButton from '../common/SiteTitleBackButton';
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 {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg';
const React = require('react');
export const SignupPageStyles = `
.gh-portal-back-sitetitle {
position: absolute;
top: 35px;
left: 32px;
}
.gh-portal-back-sitetitle .gh-portal-btn {
padding: 0;
border: 0;
font-size: 1.5rem;
height: auto;
line-height: 1em;
color: var(--grey1);
}
.gh-portal-popup-wrapper:not(.full-size) .gh-portal-back-sitetitle,
.gh-portal-popup-wrapper.preview .gh-portal-back-sitetitle {
display: none;
}
.gh-portal-signup-logo {
position: relative;
display: block;
background-position: 50%;
background-size: cover;
border-radius: 2px;
width: 60px;
height: 60px;
margin: 12px 0 10px;
}
.gh-portal-signup-header,
.gh-portal-signin-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 32px;
margin-bottom: 32px;
}
.gh-portal-popup-wrapper.full-size .gh-portal-signup-header {
margin-top: 32px;
}
.gh-portal-signup-header .gh-portal-main-title,
.gh-portal-signin-header .gh-portal-main-title {
margin-top: 12px;
}
.gh-portal-signup-logo + .gh-portal-main-title {
margin: 4px 0 0;
}
.gh-portal-signup-header .gh-portal-main-subtitle {
font-size: 1.5rem;
text-align: center;
line-height: 1.45em;
margin: 4px 0 0;
color: var(--grey3);
}
.gh-portal-logged-out-form-container {
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.signup .gh-portal-input-section:last-of-type {
margin-bottom: 40px;
}
.gh-portal-signup-message {
display: flex;
justify-content: center;
color: var(--grey4);
font-size: 1.5rem;
margin: 16px 0 0;
}
.gh-portal-signup-message,
.gh-portal-signup-message * {
z-index: 9999;
}
.full-size .gh-portal-signup-message {
margin: 24px 0 40px;
}
@media (max-width: 480px) {
.preview .gh-portal-products + .gh-portal-signup-message {
margin-bottom: 40px;
}
}
.gh-portal-signup-message button {
font-size: 1.4rem;
font-weight: 600;
margin-left: 4px !important;
}
.gh-portal-signup-message button span {
display: inline-block;
padding-bottom: 2px;
margin-bottom: -2px;
}
.gh-portal-content.signup.invite-only {
background: none;
}
footer.gh-portal-signup-footer,
footer.gh-portal-signin-footer {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
padding-top: 24px;
height: unset;
}
.gh-portal-content.signup,
.gh-portal-content.signin {
max-height: unset !important;
padding-bottom: 0;
}
.gh-portal-content.signin {
padding-bottom: 4px;
}
.gh-portal-content.signup .gh-portal-section {
margin-bottom: 0;
}
.gh-portal-content.signup.single-field {
margin-bottom: 4px;
}
.gh-portal-content.signup.single-field .gh-portal-input,
.gh-portal-content.signin .gh-portal-input {
margin-bottom: 8px;
}
.gh-portal-content.signup.single-field + .gh-portal-signup-footer,
footer.gh-portal-signin-footer {
padding-top: 12px;
}
.gh-portal-content.signin .gh-portal-section {
margin-bottom: 0;
}
footer.gh-portal-signup-footer.invite-only {
height: unset;
}
footer.gh-portal-signup-footer.invite-only .gh-portal-signup-message {
margin-top: 0;
}
.gh-portal-invite-only-notification {
margin: 8px 32px 24px;
padding: 0;
text-align: center;
color: var(--grey2);
}
.gh-portal-icon-invitation {
width: 44px;
height: 44px;
margin: 12px 0 2px;
}
.gh-portal-popup-wrapper.full-size .gh-portal-popup-container.preview footer.gh-portal-signup-footer {
padding-bottom: 32px;
}
.gh-portal-invite-only-notification + .gh-portal-signup-message {
margin-bottom: 12px;
}
.gh-portal-free-trial-notification {
max-width: 480px;
text-align: center;
margin: 24px auto;
color: var(--grey4);
}
@media (min-width: 480px) {
}
@media (max-width: 480px) {
.gh-portal-signup-logo {
width: 48px;
height: 48px;
}
}
@media (min-width: 480px) and (max-width: 820px) {
.gh-portal-powered.outside {
left: 50%;
transform: translateX(-50%);
}
}
`;
class SignupPage extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
plan: 'free',
showNewsletterSelection: false
};
}
componentDidMount() {
const {member} = this.context;
if (member) {
this.context.onAction('switchPage', {
page: 'accountHome'
});
}
// Handle the default plan if not set
this.handleSelectedPlan();
}
componentDidUpdate() {
this.handleSelectedPlan();
}
handleSelectedPlan() {
const {site, pageQuery} = this.context;
const prices = getSitePrices({site, pageQuery});
const selectedPriceId = this.getSelectedPriceId(prices, this.state.plan);
if (selectedPriceId !== this.state.plan) {
this.setState({
plan: selectedPriceId
});
}
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
handleSignup(e) {
const {site, onAction} = this.context;
e.preventDefault();
this.setState((state) => {
return {
errors: ValidateInputForm({fields: this.getInputFields({state})})
};
}, () => {
const {name, email, plan, errors} = this.state;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
if (hasMultipleNewsletters({site})) {
this.setState({
showNewsletterSelection: true,
pageData: {name, email, plan},
errors: {}
});
} else {
this.setState({
errors: {}
});
onAction('signup', {name, email, plan});
}
}
});
}
handleChooseSignup(e, plan) {
e.preventDefault();
this.setState((state) => {
return {
errors: ValidateInputForm({fields: this.getInputFields({state})})
};
}, () => {
const {onAction, site} = this.context;
const {name, email, errors} = this.state;
const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0);
if (!hasFormErrors) {
if (hasMultipleNewsletters({site})) {
this.setState({
showNewsletterSelection: true,
pageData: {name, email, plan},
errors: {}
});
} else {
onAction('signup', {name, email, plan});
this.setState({
errors: {}
});
}
}
});
}
handleInputChange(e, field) {
const fieldName = field.name;
const value = e.target.value;
this.setState({
[fieldName]: value
});
}
handleSelectPlan = (e, priceId) => {
e && e.preventDefault();
// Hack: React checkbox gets out of sync with dom state with instant update
this.timeoutId = setTimeout(() => {
this.setState((prevState) => {
return {
plan: priceId
};
});
}, 5);
};
onKeyDown(e) {
// Handles submit on Enter press
if (e.keyCode === 13){
this.handleSignup(e);
}
}
getSelectedPriceId(prices = [], selectedPriceId) {
if (!prices || prices.length === 0) {
return 'free';
}
const hasSelectedPlan = prices.some((p) => {
return p.id === selectedPriceId;
});
if (!hasSelectedPlan) {
return prices[0].id || 'free';
}
return selectedPriceId;
}
getInputFields({state, fieldNames}) {
const {portal_name: portalName} = this.context.site;
const errors = state.errors || {};
const fields = [
{
type: 'email',
value: state.email,
placeholder: 'jamie@example.com',
label: 'Email',
name: 'email',
required: true,
tabindex: 2,
errorMessage: errors.email || ''
}
];
/** Show Name field if portal option is set*/
if (portalName) {
fields.unshift({
type: 'text',
value: state.name,
placeholder: 'Jamie Larson',
label: 'Name',
name: 'name',
required: true,
tabindex: 1,
errorMessage: errors.name || ''
});
}
fields[0].autoFocus = true;
if (fieldNames && fieldNames.length > 0) {
return fields.filter((f) => {
return fieldNames.includes(f.name);
});
}
return fields;
}
renderSubmitButton() {
const {action, site, brandColor, pageQuery} = this.context;
if (isInviteOnlySite({site, pageQuery})) {
return null;
}
let label = 'Continue';
const showOnlyFree = pageQuery === 'free' && hasFreeProductPrice({site});
if (hasOnlyFreePlan({site}) || showOnlyFree) {
label = 'Sign up';
} else {
return null;
}
let isRunning = false;
if (action === 'signup:running') {
label = 'Sending...';
isRunning = true;
}
let retry = false;
if (action === 'signup:failed') {
label = 'Retry';
retry = true;
}
const disabled = (action === 'signup:running') ? true : false;
return (
<ActionButton
style={{width: '100%'}}
retry={retry}
onClick={e => this.handleSignup(e)}
disabled={disabled}
brandColor={brandColor}
label={label}
isRunning={isRunning}
tabIndex='3'
/>
);
}
renderProducts() {
const {site, pageQuery} = this.context;
const products = getSiteProducts({site, pageQuery});
return (
<>
<ProductsSection
handleChooseSignup={(...args) => this.handleChooseSignup(...args)}
products={products}
onPlanSelect={this.handleSelectPlan}
/>
</>
);
}
renderFreeTrialMessage() {
const {site} = this.context;
if (hasFreeTrialTier({site})) {
return (
<p className='gh-portal-free-trial-notification'>After a free trial ends, you will be charged regular price for the tier youve chosen. You can always cancel before then.</p>
);
}
return null;
}
renderLoginMessage() {
const {brandColor, onAction} = this.context;
return (
<div>
{this.renderFreeTrialMessage()}
<div className='gh-portal-signup-message'>
<div>Already a member?</div>
<button
className='gh-portal-btn gh-portal-btn-link'
style={{color: brandColor}}
onClick={() => onAction('switchPage', {page: 'signin'})}
>
<span>Sign in</span>
</button>
</div>
</div>
);
}
renderForm() {
const fields = this.getInputFields({state: this.state});
const {site, pageQuery} = this.context;
if (this.state.showNewsletterSelection) {
return (
<NewsletterSelectionPage
pageData={this.state.pageData}
onBack={() => {
this.setState({
showNewsletterSelection: false
});
}}
/>
);
}
if (isInviteOnlySite({site, pageQuery})) {
return (
<section>
<div className='gh-portal-section'>
<p className='gh-portal-invite-only-notification'>This site is invite-only, contact the owner for access.</p>
{this.renderLoginMessage()}
</div>
</section>
);
}
const freeBenefits = getFreeProductBenefits({site});
const freeDescription = getFreeTierDescription({site});
const showOnlyFree = pageQuery === 'free' && hasFreeProductPrice({site});
const hasOnlyFree = hasOnlyFreeProduct({site}) || showOnlyFree;
const sticky = !showOnlyFree && (freeBenefits.length || freeDescription);
return (
<section className="gh-portal-signup">
<div className='gh-portal-section'>
<div className='gh-portal-logged-out-form-container'>
<InputForm
fields={fields}
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={e => this.onKeyDown(e)}
/>
</div>
<div>
{this.renderProducts()}
{(hasOnlyFree ?
<div className={'gh-portal-btn-container' + (sticky ? ' sticky m24' : '')}>
<div className='gh-portal-logged-out-form-container'>
{this.renderSubmitButton()}
{this.renderLoginMessage()}
</div>
</div>
:
this.renderLoginMessage())}
</div>
</div>
</section>
);
}
renderSiteLogo() {
const {site, pageQuery} = this.context;
const siteLogo = site.icon;
const logoStyle = {};
if (siteLogo) {
logoStyle.backgroundImage = `url(${siteLogo})`;
return (
<img className='gh-portal-signup-logo' src={siteLogo} alt={site.title} />
);
} else if (isInviteOnlySite({site, pageQuery})) {
return (
<InvitationIcon className='gh-portal-icon gh-portal-icon-invitation' />
);
}
return null;
}
renderFormHeader() {
const {site} = this.context;
const siteTitle = site.title || '';
return (
<header className='gh-portal-signup-header'>
{this.renderSiteLogo()}
<h1 className="gh-portal-main-title">{siteTitle}</h1>
</header>
);
}
getClassNames() {
const {site, pageQuery} = this.context;
const plansData = getSitePrices({site, pageQuery});
const fields = this.getInputFields({state: this.state});
let sectionClass = '';
let footerClass = '';
if (plansData.length <= 1 || isInviteOnlySite({site})) {
if ((plansData.length === 1 && plansData[0].type === 'free') || isInviteOnlySite({site, pageQuery})) {
sectionClass = freeHasBenefitsOrDescription({site}) ? 'singleplan' : 'noplan';
if (fields.length === 1) {
sectionClass = 'single-field';
}
if (isInviteOnlySite({site})) {
footerClass = 'invite-only';
sectionClass = 'invite-only';
}
} else {
sectionClass = 'singleplan';
}
}
return {sectionClass, footerClass};
}
render() {
let {sectionClass} = this.getClassNames();
return (
<>
<div className='gh-portal-back-sitetitle'>
<SiteTitleBackButton
onBack={() => {
if (this.state.showNewsletterSelection) {
this.setState({
showNewsletterSelection: false
});
} else {
this.context.onAction('closePopup');
}
}}
/>
</div>
<CloseButton />
<div className={'gh-portal-content signup ' + sectionClass}>
{this.renderFormHeader()}
{this.renderForm()}
</div>
{/* <footer className={'gh-portal-signup-footer gh-portal-logged-out-form-container ' + footerClass}>
{this.renderSubmitButton()}
{this.renderLoginMessage()}
</footer> */}
</>
);
}
}
export default SignupPage;

View File

@ -0,0 +1,62 @@
import React from 'react';
import SignupPage from './SignupPage';
import {render, fireEvent} from '../../utils/test-utils';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<SignupPage />,
{
overrideContext: {
member: null
}
}
);
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'});
return {
nameInput,
emailInput,
submitButton,
chooseButton,
signinButton,
mockOnActionFn,
...utils
};
};
describe('SignupPage', () => {
test('renders', () => {
const {nameInput, emailInput, queryAllByRole, signinButton} = setup();
const chooseButton = queryAllByRole('button', {name: 'Continue'});
expect(nameInput).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(chooseButton).toHaveLength(1);
expect(signinButton).toBeInTheDocument();
});
test('can call signup action with name, email and plan', () => {
const {nameInput, emailInput, chooseButton, mockOnActionFn} = setup();
const nameVal = 'J Smith';
const emailVal = 'jsmith@example.com';
const planVal = 'free';
fireEvent.change(nameInput, {target: {value: nameVal}});
fireEvent.change(emailInput, {target: {value: emailVal}});
expect(nameInput).toHaveValue(nameVal);
expect(emailInput).toHaveValue(emailVal);
fireEvent.click(chooseButton[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('signup', {email: emailVal, name: nameVal, plan: planVal});
});
test('can call swithPage for signin', () => {
const {signinButton, mockOnActionFn} = setup();
fireEvent.click(signinButton);
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin'});
});
});

View File

@ -0,0 +1,181 @@
import AppContext from '../../AppContext';
import {useContext, useEffect, useState} from 'react';
import {getSiteNewsletters} from '../../utils/helpers';
import setupGhostApi from '../../utils/api';
import NewsletterManagement from '../common/NewsletterManagement';
import CloseButton from '../common/CloseButton';
const React = require('react');
function SiteLogo() {
const {site} = useContext(AppContext);
const siteLogo = site.icon;
if (siteLogo) {
return (
<img className='gh-portal-unsubscribe-logo' src={siteLogo} alt={site.title} />
);
}
return (null);
}
function AccountHeader() {
const {site} = useContext(AppContext);
const siteTitle = site.title || '';
return (
<header className='gh-portal-header'>
<SiteLogo />
<h2 className="gh-portal-publication-title">{siteTitle}</h2>
</header>
);
}
async function updateMemberNewsletters({api, memberUuid, newsletters, enableCommentNotifications}) {
try {
return await api.member.updateNewsletters({uuid: memberUuid, newsletters, enableCommentNotifications});
} catch (e) {
// ignore auto unsubscribe error
}
}
export default function UnsubscribePage() {
const {site, pageData, onAction} = useContext(AppContext);
const api = setupGhostApi({siteUrl: site.url});
const [member, setMember] = useState();
const siteNewsletters = getSiteNewsletters({site});
const defaultNewsletters = siteNewsletters.filter((d) => {
return d.subscribe_on_signup;
});
const [hasInteracted, setHasInteracted] = useState(false);
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultNewsletters);
const [showPrefs, setShowPrefs] = useState(false);
const {comments_enabled: commentsEnabled} = site;
const {enable_comment_notifications: enableCommentNotifications = false} = member || {};
useEffect(() => {
const ghostApi = setupGhostApi({siteUrl: site.url});
(async () => {
const memberData = await ghostApi.member.newsletters({uuid: pageData.uuid});
setMember(memberData);
const memberNewsletters = memberData?.newsletters || [];
setSubscribedNewsletters(memberNewsletters);
if (siteNewsletters?.length === 1 && !commentsEnabled) {
// Unsubscribe from all the newsletters, because we only have one
const updatedData = await updateMemberNewsletters({
api: ghostApi,
memberUuid: pageData.uuid,
newsletters: []
});
setSubscribedNewsletters(updatedData.newsletters);
} else if (pageData.newsletterUuid) {
// Unsubscribe link for a specific newsletter
const updatedData = await updateMemberNewsletters({
api: ghostApi,
memberUuid: pageData.uuid,
newsletters: memberNewsletters?.filter((d) => {
return d.uuid !== pageData.newsletterUuid;
})
});
setSubscribedNewsletters(updatedData.newsletters);
} else if (pageData.comments && commentsEnabled) {
// Unsubscribe link for comments
const updatedData = await updateMemberNewsletters({
api: ghostApi,
memberUuid: pageData.uuid,
enableCommentNotifications: false
});
setMember(updatedData);
}
})();
}, [commentsEnabled, pageData.uuid, pageData.newsletterUuid, pageData.comments, site.url, siteNewsletters?.length]);
// Case: Email not found
if (member === null) {
return (
<div className='gh-portal-content gh-portal-unsubscribe with-footer'>
<CloseButton />
<AccountHeader />
<h1 className="gh-portal-main-title">Unsubscribe failed</h1>
<div>
<p className="gh-portal-text-center">Email address not found.</p>
</div>
</div>
);
}
// Case: Single active newsletter
if (siteNewsletters?.length === 1 && !commentsEnabled && !showPrefs) {
return (
<div className='gh-portal-content gh-portal-unsubscribe with-footer'>
<CloseButton />
<AccountHeader />
<h1 className="gh-portal-main-title">Successfully unsubscribed</h1>
<div>
<p className='gh-portal-text-center'><strong>{member?.email}</strong> will no longer receive this newsletter.</p>
<p className='gh-portal-text-center'>Didn't mean to do this? Manage your preferences
<button
className="gh-portal-btn-link gh-portal-btn-branded gh-portal-btn-inline"
onClick={() => {
setShowPrefs(true);
}}
>
here
</button>.
</p>
</div>
</div>
);
}
const HeaderNotification = () => {
if (pageData.comments && commentsEnabled) {
const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
return (
<>
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}><strong>{member?.email}</strong> will no longer receive emails when someone replies to your comments.</p>
</>
);
}
const unsubscribedNewsletter = siteNewsletters?.find((d) => {
return d.uuid === pageData.newsletterUuid;
});
const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
return (
<>
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}><strong>{member?.email}</strong> will no longer receive <strong>{unsubscribedNewsletter?.name}</strong> newsletter.</p>
</>
);
};
return (
<NewsletterManagement
notification={HeaderNotification}
subscribedNewsletters={subscribedNewsletters}
updateSubscribedNewsletters={async (newsletters) => {
setSubscribedNewsletters(newsletters);
setHasInteracted(true);
await api.member.updateNewsletters({uuid: pageData.uuid, newsletters});
}}
updateCommentNotifications={async (enabled) => {
const updatedMember = await api.member.updateNewsletters({uuid: pageData.uuid, enableCommentNotifications: enabled});
setMember(updatedMember);
}}
unsubscribeAll={async () => {
setHasInteracted(true);
setSubscribedNewsletters([]);
onAction('showPopupNotification', {
action: 'updated:success',
message: `Email preference updated.`
});
const updatedMember = await api.member.updateNewsletters({uuid: pageData.uuid, newsletters: [], enableCommentNotifications: false});
setMember(updatedMember);
}}
isPaidMember={member?.status !== 'free'}
isCommentsEnabled={commentsEnabled !== 'off'}
enableCommentNotifications={enableCommentNotifications}
/>
);
}

View File

@ -0,0 +1,353 @@
/* eslint-disable no-console */
import {getQueryPrice, getUrlHistory} from './utils/helpers';
export function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}) {
form.removeEventListener('submit', submitHandler);
event.preventDefault();
if (errorEl) {
errorEl.innerText = '';
}
form.classList.remove('success', 'invalid', 'error');
let emailInput = event.target.querySelector('input[data-members-email]');
let nameInput = event.target.querySelector('input[data-members-name]');
let autoRedirect = form?.dataset?.membersAutoredirect || 'true';
let email = emailInput?.value;
let name = (nameInput && nameInput.value) || undefined;
let emailType = undefined;
let labels = [];
let labelInputs = event.target.querySelectorAll('input[data-members-label]') || [];
for (let i = 0; i < labelInputs.length; ++i) {
labels.push(labelInputs[i].value);
}
if (form.dataset.membersForm) {
emailType = form.dataset.membersForm;
}
form.classList.add('loading');
const urlHistory = getUrlHistory();
const reqBody = {
email: email,
emailType: emailType,
labels: labels,
name: name,
autoRedirect: (autoRedirect === 'true')
};
if (urlHistory) {
reqBody.urlHistory = urlHistory;
}
fetch(`${siteUrl}/members/api/send-magic-link/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(reqBody)
}).then(function (res) {
form.addEventListener('submit', submitHandler);
form.classList.remove('loading');
if (res.ok) {
form.classList.add('success');
} else {
if (errorEl) {
errorEl.innerText = 'There was an error sending the email, please try again';
}
form.classList.add('error');
}
});
}
export function planClickHandler({event, el, errorEl, siteUrl, site, member, clickHandler}) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
let plan = el.dataset.membersPlan;
let priceId = '';
if (plan) {
const price = getQueryPrice({site, priceId: plan.toLowerCase()});
priceId = price ? price.id : plan;
}
let successUrl = el.dataset.membersSuccess;
let cancelUrl = el.dataset.membersCancel;
let checkoutSuccessUrl;
let checkoutCancelUrl;
if (successUrl) {
checkoutSuccessUrl = (new URL(successUrl, window.location.href)).href;
}
if (cancelUrl) {
checkoutCancelUrl = (new URL(cancelUrl, window.location.href)).href;
}
if (errorEl) {
errorEl.innerText = '';
}
el.classList.add('loading');
const metadata = member ? {
checkoutType: 'upgrade'
} : {};
const urlHistory = getUrlHistory();
if (urlHistory) {
metadata.urlHistory = urlHistory;
}
return fetch(`${siteUrl}/members/api/session`, {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`${siteUrl}/members/api/create-stripe-checkout-session/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
priceId: priceId,
identity: identity,
successUrl: checkoutSuccessUrl,
cancelUrl: checkoutCancelUrl,
metadata
})
}).then(function (res) {
if (!res.ok) {
throw new Error('Could not create stripe checkout session');
}
return res.json();
});
}).then(function (result) {
let stripe = window.Stripe(result.publicKey);
return stripe.redirectToCheckout({
sessionId: result.sessionId
});
}).then(function (result) {
if (result.error) {
throw new Error(result.error.message);
}
}).catch(function (err) {
console.error(err);
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
if (errorEl) {
errorEl.innerText = err.message;
}
el.classList.add('error');
});
}
export function handleDataAttributes({siteUrl, site, member}) {
if (!siteUrl) {
return;
}
siteUrl = siteUrl.replace(/\/$/, '');
Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) {
let errorEl = form.querySelector('[data-members-error]');
function submitHandler(event) {
formSubmitHandler({event, errorEl, form, siteUrl, submitHandler});
}
form.addEventListener('submit', submitHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), function (el) {
let errorEl = el.querySelector('[data-members-error]');
function clickHandler(event) {
planClickHandler({el, event, errorEl, member, site, siteUrl, clickHandler});
}
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-edit-billing]'), function (el) {
let errorEl = el.querySelector('[data-members-error]');
let membersSuccess = el.dataset.membersSuccess;
let membersCancel = el.dataset.membersCancel;
let successUrl;
let cancelUrl;
if (membersSuccess) {
successUrl = (new URL(membersSuccess, window.location.href)).href;
}
if (membersCancel) {
cancelUrl = (new URL(membersCancel, window.location.href)).href;
}
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
if (errorEl) {
errorEl.innerText = '';
}
el.classList.add('loading');
fetch(`${siteUrl}/members/api/session`, {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`${siteUrl}/members/api/create-stripe-update-session/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity: identity,
successUrl: successUrl,
cancelUrl: cancelUrl
})
}).then(function (res) {
if (!res.ok) {
throw new Error('Could not create stripe checkout session');
}
return res.json();
});
}).then(function (result) {
let stripe = window.Stripe(result.publicKey);
return stripe.redirectToCheckout({
sessionId: result.sessionId
});
}).then(function (result) {
if (result.error) {
throw new Error(result.error.message);
}
}).catch(function (err) {
console.error(err);
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
if (errorEl) {
errorEl.innerText = err.message;
}
el.classList.add('error');
});
}
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]'), function (el) {
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
el.classList.remove('error');
el.classList.add('loading');
fetch(`${siteUrl}/members/api/session`, {
method: 'DELETE'
}).then(function (res) {
if (res.ok) {
window.location.replace(siteUrl);
} else {
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
el.classList.add('error');
}
});
}
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-cancel-subscription]'), function (el) {
let errorEl = el.parentElement.querySelector('[data-members-error]');
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
el.classList.remove('error');
el.classList.add('loading');
let subscriptionId = el.dataset.membersCancelSubscription;
if (errorEl) {
errorEl.innerText = '';
}
return fetch(`${siteUrl}/members/api/session`, {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity: identity,
smart_cancel: true
})
});
}).then(function (res) {
if (res.ok) {
window.location.reload();
} else {
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
el.classList.add('error');
if (errorEl) {
errorEl.innerText = 'There was an error cancelling your subscription, please try again.';
}
}
});
}
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-continue-subscription]'), function (el) {
let errorEl = el.parentElement.querySelector('[data-members-error]');
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
el.classList.remove('error');
el.classList.add('loading');
let subscriptionId = el.dataset.membersContinueSubscription;
if (errorEl) {
errorEl.innerText = '';
}
return fetch(`${siteUrl}/members/api/session`, {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`${siteUrl}/members/api/subscriptions/${subscriptionId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity: identity,
cancel_at_period_end: false
})
});
}).then(function (res) {
if (res.ok) {
window.location.reload();
} else {
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
el.classList.add('error');
if (errorEl) {
errorEl.innerText = 'There was an error continuing your subscription, please try again.';
}
}
});
}
el.addEventListener('click', clickHandler);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 12C24 18.6274 18.6274 24 12 24C5.37258 24 0 18.6274 0 12C0 5.37258 5.37258 0 12 0C18.6274 0 24 5.37258 24 12ZM11.8326 2.33879C6.37785 2.95189 3.95901 5.20797 3.41126 9.74699C3.34896 10.2632 3.22642 10.7805 3.10443 11.2954C2.93277 12.02 2.76221 12.74 2.76221 13.4458C2.76221 17.9885 6.5856 21.556 11.1283 21.556C12.8959 21.556 14.4433 20.8144 15.8756 20.048C19.0536 18.3478 22.0328 16.2597 22.0328 12.5411C22.0328 9.91512 20.1051 7.56932 18.466 5.5747C18.3834 5.47416 18.3015 5.37451 18.2206 5.27577C17.3866 4.25742 14.4333 2.04643 11.8326 2.33879Z" fill="#15171A"/>
</svg>

After

Width:  |  Height:  |  Size: 722 B

View File

@ -0,0 +1 @@
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M16.25,23.25,5.53,12.53a.749.749,0,0,1,0-1.06L16.25.75"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@ -0,0 +1 @@
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M5.5.75,16.22,11.47a.749.749,0,0,1,0,1.06L5.5,23.25"/></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@ -0,0 +1 @@
<svg width="21" height="24" viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.533 11.267c2.835 0 5.134-2.299 5.134-5.134C15.667 3.298 13.368 1 10.533 1 7.698 1 5.4 3.298 5.4 6.133s2.298 5.134 5.133 5.134zM1 23c0-2.529 1.004-4.953 2.792-6.741 1.788-1.788 4.213-2.792 6.741-2.792 2.529 0 4.954 1.004 6.741 2.792 1.788 1.788 2.793 4.212 2.793 6.74" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path stroke="#FFF" stroke-width="1.5" stroke-linecap="round" d="M12.5 2v20M2 12.5h20"/></g></svg>

After

Width:  |  Height:  |  Size: 216 B

View File

@ -0,0 +1 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.5 6v14.25c0 .597-.237 1.169-.659 1.591-.422.422-.994.659-1.591.659s-1.169-.237-1.591-.659c-.422-.422-.659-.994-.659-1.591V3c0-.398-.158-.78-.44-1.06-.28-.282-.662-.44-1.06-.44h-15c-.398 0-.78.158-1.06.44C1.157 2.22 1 2.601 1 3v17.25c0 .597.237 1.169.659 1.591.422.422.994.659 1.591.659h18M4.75 15h10.5M4.75 18h6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.5 5.25h-9c-.414 0-.75.336-.75.75v4.5c0 .414.336.75.75.75h9c.414 0 .75-.336.75-.75V6c0-.414-.336-.75-.75-.75z" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 712 B

View File

@ -0,0 +1 @@
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21.75 1.5H2.25c-.828 0-1.5.672-1.5 1.5v12c0 .828.672 1.5 1.5 1.5h19.5c.828 0 1.5-.672 1.5-1.5V3c0-.828-.672-1.5-1.5-1.5zM15.687 6.975L19.5 10.5M8.313 6.975L4.5 10.5" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M22.88 2.014l-9.513 6.56C12.965 8.851 12.488 9 12 9s-.965-.149-1.367-.426L1.12 2.014" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@ -0,0 +1 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.903 12.016c-.332-1.665-1.491-3.032-3.031-3.654M11.037 8.4C9.252 9.163 8 10.935 8 13c0 .432.055.85.158 1.25M10.44 17.296c.748.447 1.624.704 2.56.704 1.71 0 3.22-.858 4.12-2.167M15.171 21.22c3.643-.96 6.329-4.276 6.329-8.22 0-1.084-.203-2.121-.573-3.075M18.611 6.615C17.114 5.3 15.151 4.5 13 4.5c-2.149 0-4.112.797-5.608 2.113M5.112 9.826c-.395.98-.612 2.052-.612 3.174 0 4.015 2.783 7.38 6.526 8.27" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/><path d="M8.924 24.29c1.273.46 2.645.71 4.076.71 5.52 0 10.17-3.727 11.57-8.803M6.712 2.777C3.285 4.89 1 8.678 1 13c0 3.545 1.537 6.731 3.982 8.928M24.849 11.089C23.933 5.369 18.977 1 13 1c-.69 0-1.367.058-2.025.17" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 843 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px!important;}
</style>
</defs>
<title>check-circle-1</title>
<path class="a" d="M6,13.223,8.45,16.7a1.049,1.049,0,0,0,1.707.051L18,6.828"/>
<circle class="a" cx="12" cy="11.999" r="11.25"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.checkmark-icon-fill{fill:currentColor;}</style></defs><path class="checkmark-icon-fill" d="M12,0A12,12,0,1,0,24,12,12.014,12.014,0,0,0,12,0Zm6.927,8.2-6.845,9.289a1.011,1.011,0,0,1-1.43.188L5.764,13.769a1,1,0,1,1,1.25-1.562l4.076,3.261,6.227-8.451A1,1,0,1,1,18.927,8.2Z"/></svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 6.89286L6.10714 12L13.9643 1" stroke="#222222" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 180 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.2px !important;}</style></defs><path class="a" d="M.75 23.249l22.5-22.5M23.25 23.249L.75.749"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1px;}</style></defs><rect class="a" x="0.75" y="4.5" width="22.5" height="15" rx="1.5" ry="1.5"/><line class="a" x1="15.687" y1="9.975" x2="19.5" y2="13.5"/><line class="a" x1="8.313" y1="9.975" x2="4.5" y2="13.5"/><path class="a" d="M22.88,5.014l-9.513,6.56a2.406,2.406,0,0,1-2.734,0L1.12,5.014"/></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><defs><style>.inviteicon{fill: currentColor;}</style></defs><path class="inviteicon" d="M23.991 11.464l-.036-.146-.028-.068-.011-.027-.115-.114-.018-.021-.008-.005h-.001l-3.774-2.596v-7.987c0-.276-.224-.5-.5-.5h-15c-.276 0-.5.224-.5.5v7.987l-3.774 2.595-.003.002-.006.004-.015.016-.118.118-.011.027-.028.068-.036.146-.009.037v10.5c0 1.103.897 2 2 2h20c1.103 0 2-.897 2-2v-10.5l-.009-.036zm-1.383.03l-2.608 1.738v-3.531l2.608 1.793zm-18.608 1.738l-2.608-1.739 2.608-1.792v3.531zm18 9.768h-20c-.551 0-1-.449-1-1v-9.566l5.223 3.482c.085.057.181.084.276.084.162 0 .32-.078.417-.223.153-.23.091-.54-.139-.693l-1.777-1.185v-12.899h14v12.899l-1.777 1.185c-.23.153-.292.463-.139.693.096.145.255.223.416.223.095 0 .191-.027.277-.084l5.223-3.482v9.566c0 .551-.449 1-1 1zM15.812 16.109c-.088-.07-.198-.109-.312-.109h-7c-.114 0-.224.039-.312.109l-5 4c-.215.173-.25.487-.078.703.173.215.487.251.703.078l4.862-3.89h6.649l4.863 3.891c.093.073.203.109.313.109.147 0 .292-.065.391-.188.172-.216.137-.53-.078-.703l-5.001-4zM11.706 12.779c.087.064.191.096.294.096s.207-.032.294-.096c.482-.35 4.706-3.497 4.706-6.101 0-1.868-1.387-2.984-2.728-2.984-.772 0-1.674.379-2.272 1.368-.598-.988-1.5-1.368-2.272-1.368-1.341-.001-2.728 1.116-2.728 2.984 0 2.604 4.224 5.751 4.706 6.101zm-1.978-8.086c.844 0 1.511.681 1.786 1.822.108.45.864.45.973 0 .274-1.141.942-1.822 1.786-1.822.85 0 1.728.742 1.728 1.984 0 1.646-2.658 4.037-4 5.072-1.342-1.035-4-3.426-4-5.072-.001-1.241.877-1.984 1.727-1.984z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,11 @@
<svg version="1.1" id="loader-1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="40px" height="40px" viewBox="0 0 40 40" enable-background="new 0 0 40 40" xml:space="preserve">
<path opacity="0.2" fill="#000" d="M20.201,5.169c-8.254,0-14.946,6.692-14.946,14.946c0,8.255,6.692,14.946,14.946,14.946
s14.946-6.691,14.946-14.946C35.146,11.861,28.455,5.169,20.201,5.169z M20.201,31.749c-6.425,0-11.634-5.208-11.634-11.634
c0-6.425,5.209-11.634,11.634-11.634c6.425,0,11.633,5.209,11.633,11.634C31.834,26.541,26.626,31.749,20.201,31.749z" />
<path fill="#000" d="M26.013,10.047l1.654-2.866c-2.198-1.272-4.743-2.012-7.466-2.012h0v3.312h0
C22.32,8.481,24.301,9.057,26.013,10.047z">
<animateTransform attributeType="xml" attributeName="transform" type="rotate" from="0 20 20" to="360 20 20"
dur="0.5s" repeatCount="indefinite" />
</path>
</svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g transform="matrix(0.6666666666666666,0,0,0.6666666666666666,0,0)"><path d="M19.5,9.5h-.75V6.75a6.75,6.75,0,0,0-13.5,0V9.5H4.5a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2h15a2,2,0,0,0,2-2V11.5A2,2,0,0,0,19.5,9.5Zm-7.5,9a2,2,0,1,1,2-2A2,2,0,0,1,12,18.5ZM16.25,9a.5.5,0,0,1-.5.5H8.25a.5.5,0,0,1-.5-.5V6.75a4.25,4.25,0,0,1,8.5,0Z" style="fill: #000000"></path></g></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@ -0,0 +1 @@
<svg width="25" height="23" viewBox="0 0 25 23" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.497 11.5H7.747M11.497 7.75l-3.75 3.75 3.75 3.75" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.94 6.904c-1.031-2.12-2.747-3.83-4.87-4.857-2.123-1.027-4.53-1.308-6.832-.8-2.303.508-4.367 1.776-5.861 3.6-1.494 1.824-2.33 4.098-2.375 6.456-.044 2.358.706 4.661 2.13 6.54 1.425 1.88 3.441 3.224 5.723 3.818 2.282.594 4.698.403 6.857-.543 2.16-.946 3.94-2.592 5.05-4.672" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs>
<path d="M22.939 2.56V8.817C22.9391 9.61244 22.6232 10.3754 22.061 10.938L10.5 22.5C10.2187 22.7812 9.83721 22.9392 9.43946 22.9392C9.04172 22.9392 8.66026 22.7812 8.37896 22.5L1.49997 15.62C1.21876 15.3387 1.06079 14.9572 1.06079 14.5595C1.06079 14.1618 1.21876 13.7803 1.49997 13.499L13.061 1.938C13.6236 1.37572 14.3865 1.0599 15.182 1.06H21.439C21.8368 1.06 22.2183 1.21803 22.4996 1.49934C22.7809 1.78064 22.939 2.16217 22.939 2.56V2.56Z" class="a" />
<path d="M17.689 7.81C16.8605 7.81 16.189 7.13842 16.189 6.31C16.189 5.48157 16.8605 4.81 17.689 4.81C18.5174 4.81 19.189 5.48157 19.189 6.31C19.189 7.13842 18.5174 7.81 17.689 7.81Z" class="a" />
</svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@ -0,0 +1 @@
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.8px;}</style></defs><circle class="cls-1" cx="12" cy="9.75" r="5.25"/><path class="cls-1" d="M18.913,20.876a9.746,9.746,0,0,0-13.826,0"/><circle class="cls-1" cx="12" cy="12" r="11.25"/></svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.warning-icon-fill{fill:currentColor;}</style></defs><path class="warning-icon-fill" d="M23.25,23.235a.75.75,0,0,0,.661-1.105l-11.25-21a.782.782,0,0,0-1.322,0l-11.25,21A.75.75,0,0,0,.75,23.235ZM12,20.485a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,12,20.485Zm0-12.25a1,1,0,0,1,1,1V14.7a1,1,0,0,1-2,0V9.235A1,1,0,0,1,12,8.235Z"/></svg>

After

Width:  |  Height:  |  Size: 399 B

View File

64
ghost/portal/src/index.js Normal file
View File

@ -0,0 +1,64 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import setupAnalytics from './analytics';
const ROOT_DIV_ID = 'ghost-portal-root';
function addRootDiv() {
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
document.body.appendChild(elem);
}
function getSiteData() {
/**
* @type {HTMLElement}
*/
const scriptTag = document.querySelector('script[data-ghost]');
if (scriptTag) {
const siteUrl = scriptTag.dataset.ghost;
const apiKey = scriptTag.dataset.key;
const apiUrl = scriptTag.dataset.api;
return {siteUrl, apiKey, apiUrl};
}
return {};
}
function handleTokenUrl() {
const url = new URL(window.location.href);
if (url.searchParams.get('token')) {
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.href);
}
}
function setupAnalyticsScript({siteUrl}) {
const analyticsTag = document.querySelector('meta[name=ghost-analytics-id]');
const analyticsId = analyticsTag?.content;
if (siteUrl && analyticsTag) {
setupAnalytics({siteUrl, analyticsId});
}
}
function setup({siteUrl}) {
addRootDiv();
handleTokenUrl();
setupAnalyticsScript({siteUrl});
}
function init() {
// const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, apiKey, apiUrl} = getSiteData();
const siteUrl = customSiteUrl || window.location.origin;
setup({siteUrl});
ReactDOM.render(
<React.StrictMode>
<App siteUrl={siteUrl} customSiteUrl={customSiteUrl} apiKey={apiKey} apiUrl={apiUrl} />
</React.StrictMode>,
document.getElementById(ROOT_DIV_ID)
);
}
init();

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

46
ghost/portal/src/pages.js Normal file
View File

@ -0,0 +1,46 @@
import SigninPage from './components/pages/SigninPage';
import SignupPage from './components/pages/SignupPage';
import AccountHomePage from './components/pages/AccountHomePage';
import MagicLinkPage from './components/pages/MagicLinkPage';
import LoadingPage from './components/pages/LoadingPage';
import AccountPlanPage from './components/pages/AccountPlanPage';
import AccountProfilePage from './components/pages/AccountProfilePage';
import AccountEmailPage from './components/pages/AccountEmailPage';
import OfferPage from './components/pages/OfferPage';
import NewsletterSelectionPage from './components/pages/NewsletterSelectionPage';
import UnsubscribePage from './components/pages/UnsubscribePage';
/** List of all available pages in Portal, mapped to their UI component
* Any new page added to portal needs to be mapped here
*/
const Pages = {
signin: SigninPage,
signup: SignupPage,
accountHome: AccountHomePage,
accountPlan: AccountPlanPage,
accountProfile: AccountProfilePage,
accountEmail: AccountEmailPage,
signupNewsletter: NewsletterSelectionPage,
unsubscribe: UnsubscribePage,
magiclink: MagicLinkPage,
loading: LoadingPage,
offer: OfferPage
};
/** Return page if valid, fallback to signup */
export const getActivePage = function ({page}) {
if (Object.keys(Pages).includes(page)) {
return page;
}
return 'signup';
};
export const isAccountPage = function ({page}) {
return page.includes('account');
};
export const isOfferPage = function ({page}) {
return page.includes('offer');
};
export default Pages;

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View File

@ -0,0 +1,290 @@
import React from 'react';
import App from '../App.js';
import {fireEvent, appRender, within} from '../utils/test-utils';
import {site as FixtureSite} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
const setup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
...utils
};
};
const multiTierSetup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const freeTierDescription = site.products?.find(p => p.type === 'free')?.description;
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryAllByText(/free$/i);
const freePlanDescription = within(popupIframeDocument).queryAllByText(freeTierDescription);
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
freePlanDescription,
...utils
};
};
const realLocation = window.location;
describe('Signin', () => {
describe('on single tier site', () => {
beforeEach(() => {
// Mock window.location
Object.defineProperty(window, 'location', {
value: new URL('https://portal.localhost/#/portal/signin'),
writable: true
});
});
afterEach(() => {
window.location = realLocation;
});
test.only('with default settings', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,popupIframeDocument
} = await setup({
site: FixtureSite.singleTier.basic
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
test.only('without name field', async () => {
const {ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,
popupIframeDocument} = await setup({
site: FixtureSite.singleTier.withoutName
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
test.only('with only free plan', async () => {
let {ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,
popupIframeDocument} = await setup({
site: FixtureSite.singleTier.onlyFreePlan
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
});
});
describe('Signin', () => {
describe('on multi tier site', () => {
beforeEach(() => {
// Mock window.location
Object.defineProperty(window, 'location', {
value: new URL('https://portal.localhost/#/portal/signin'),
writable: true
});
});
afterEach(() => {
window.location = realLocation;
});
test.only('with default settings', async () => {
const {ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,
popupIframeDocument} = await multiTierSetup({
site: FixtureSite.multipleTiers.basic
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
test.only('without name field', async () => {
const {ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,
popupIframeDocument} = await multiTierSetup({
site: FixtureSite.multipleTiers.withoutName
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
test.only('with only free plan available', async () => {
let {ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, submitButton,
popupIframeDocument} = await multiTierSetup({
site: FixtureSite.multipleTiers.onlyFreePlan
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com'
});
const magicLink = await within(popupIframeDocument).findByText(/sent you a login link/i);
expect(magicLink).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,768 @@
import React from 'react';
import App from '../App.js';
import {fireEvent, appRender, within, waitFor} from '../utils/test-utils';
import {offer as FixtureOffer, site as FixtureSite} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
const offerSetup = async ({site, member = null, offer}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.site.offer = jest.fn(() => {
return Promise.resolve({
offers: [offer]
});
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const popupFrame = await utils.findByTitle(/portal-popup/i);
const triggerButtonFrame = await utils.queryByTitle(/portal-trigger/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const chooseBtns = within(popupIframeDocument).queryAllByRole('button', {name: 'Choose'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const offerName = within(popupIframeDocument).queryByText(offer.display_title);
const offerDescription = within(popupIframeDocument).queryByText(offer.display_description);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
chooseBtns,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
offerName,
offerDescription,
...utils
};
};
const setup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const chooseBtns = within(popupIframeDocument).queryAllByRole('button', {name: 'Choose'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
chooseBtns,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
...utils
};
};
const multiTierSetup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const freeTierDescription = site.products?.find(p => p.type === 'free')?.description;
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const chooseBtns = within(popupIframeDocument).queryAllByRole('button', {name: 'Choose'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryAllByText(/free$/i);
const freePlanDescription = within(popupIframeDocument).queryAllByText(freeTierDescription);
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
freePlanDescription,
chooseBtns,
...utils
};
};
describe('Signup', () => {
describe('as free member on single tier site', () => {
test('with default settings', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton,
siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle, chooseBtns
} = await setup({
site: FixtureSite.singleTier.basic
});
const continueButton = within(popupIframeDocument).queryAllByRole('button', {name: 'Continue'});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle).toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
// expect(fullAccessTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
// expect(submitButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(1);
expect(continueButton).toHaveLength(1);
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(chooseBtns[0]);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
plan: 'free'
});
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
test('without name field', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton,
siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle, chooseBtns
} = await setup({
site: FixtureSite.singleTier.withoutName
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(freePlanTitle).toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
// expect(fullAccessTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(1);
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(chooseBtns[0]);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: '',
plan: 'free'
});
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
test('with only free plan', async () => {
let {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle, fullAccessTitle
} = await setup({
site: FixtureSite.singleTier.onlyFreePlan
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle).not.toBeInTheDocument();
expect(monthlyPlanTitle).not.toBeInTheDocument();
expect(yearlyPlanTitle).not.toBeInTheDocument();
expect(fullAccessTitle).not.toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(submitButton).not.toBeInTheDocument();
submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign up'});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
plan: 'free'
});
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
});
describe('as paid member on single tier site', () => {
test('with default settings on monthly plan', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns,
siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle, submitButton
} = await setup({
site: FixtureSite.singleTier.basic
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle).toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(1);
const monthlyPlanContainer = within(popupIframeDocument).queryByText(/Monthly$/);
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
const benefitText = singleTierProduct.benefits[0].name;
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(monthlyPlanContainer.parentNode);
await within(popupIframeDocument).findByText(benefitText);
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
offerId: undefined,
plan: singleTierProduct.yearlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'year'
});
});
test('with default settings on yearly plan', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns, submitButton, siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle
} = await setup({
site: FixtureSite.singleTier.basic
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle).toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(1);
const yearlyPlanContainer = within(popupIframeDocument).queryByText(/Yearly$/);
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
const benefitText = singleTierProduct.benefits[0].name;
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(yearlyPlanContainer.parentNode);
await within(popupIframeDocument).findByText(benefitText);
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
offerId: undefined,
plan: singleTierProduct.yearlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'year'
});
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
test('without name field on monthly plan', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns,
siteTitle, popupIframeDocument, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle, submitButton
} = await setup({
site: FixtureSite.singleTier.withoutName
});
const monthlyPlanContainer = within(popupIframeDocument).queryByText(/Monthly$/);
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
const benefitText = singleTierProduct.benefits[0].name;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(freePlanTitle).toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(1);
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(monthlyPlanContainer);
await within(popupIframeDocument).findByText(benefitText);
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: '',
offerId: undefined,
plan: singleTierProduct.monthlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'month'
});
});
test('with only paid plans available', async () => {
let {
ghostApi, popupFrame, popupIframeDocument, triggerButtonFrame, emailInput, nameInput, signinButton,
siteTitle, freePlanTitle, monthlyPlanTitle, yearlyPlanTitle
} = await setup({
site: FixtureSite.singleTier.onlyPaidPlan
});
const submitButton = within(popupIframeDocument).queryAllByRole('button', {name: 'Continue'});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle).not.toBeInTheDocument();
expect(monthlyPlanTitle).toBeInTheDocument();
expect(yearlyPlanTitle).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(submitButton).toHaveLength(1);
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton[0]);
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
offerId: undefined,
plan: singleTierProduct.yearlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'year'
});
});
test('to an offer via link', async () => {
window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site: FixtureSite.singleTier.basic,
offer: FixtureOffer
});
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
expect(offerName).toBeInTheDocument();
expect(offerDescription).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
offerId,
plan: planId,
tierId: tier.id,
cadence: 'month'
});
window.location.hash = '';
});
test('to an offer via link with portal disabled', async () => {
let site = {
...FixtureSite.singleTier.basic,
portal_button: false
};
window.location.hash = `#/portal/offers/${FixtureOffer.id}`;
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site,
offer: FixtureOffer
});
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).not.toBeInTheDocument();
expect(siteTitle).not.toBeInTheDocument();
expect(emailInput).not.toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(signinButton).not.toBeInTheDocument();
expect(submitButton).not.toBeInTheDocument();
expect(offerName).not.toBeInTheDocument();
expect(offerDescription).not.toBeInTheDocument();
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: undefined,
name: undefined,
offerId: offerId,
plan: planId,
tierId: tier.id,
cadence: 'month'
});
window.location.hash = '';
});
});
});
describe('Signup', () => {
describe('as free member on multi tier site', () => {
test('with default settings', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns,
siteTitle, popupIframeDocument, freePlanTitle
} = await multiTierSetup({
site: FixtureSite.multipleTiers.basic
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle[0]).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(4);
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(chooseBtns[0]);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
plan: 'free'
});
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
test('without name field', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns,
siteTitle, popupIframeDocument, freePlanTitle
} = await multiTierSetup({
site: FixtureSite.multipleTiers.withoutName
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(freePlanTitle[0]).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(chooseBtns[0]);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: '',
plan: 'free'
});
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
test('with only free plan available', async () => {
let {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle, popupIframeDocument, freePlanTitle
} = await multiTierSetup({
site: FixtureSite.multipleTiers.onlyFreePlan
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle.length).toBe(0);
expect(signinButton).toBeInTheDocument();
expect(submitButton).not.toBeInTheDocument();
submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign up'});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
plan: 'free'
});
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
});
});
describe('as paid member on multi tier site', () => {
test('with default settings', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, chooseBtns,
siteTitle, popupIframeDocument, freePlanTitle
} = await multiTierSetup({
site: FixtureSite.multipleTiers.basic
});
const firstPaidTier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
const regex = new RegExp(`${firstPaidTier.name}$`);
const tierContainer = within(popupIframeDocument).queryAllByText(regex);
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(freePlanTitle[0]).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(chooseBtns).toHaveLength(4);
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(tierContainer[0]);
const labelText = popupIframeDocument.querySelector('.gh-portal-discount-label');
await waitFor(() => {
expect(labelText).toBeInTheDocument();
});
// added fake timeout for react state delay in setting plan
await new Promise((r) => {
setTimeout(r, 10);
});
fireEvent.click(chooseBtns[1]);
await waitFor(() => expect(ghostApi.member.checkoutPlan).toHaveBeenCalledTimes(1));
});
test('to an offer via link', async () => {
window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site: FixtureSite.multipleTiers.basic,
offer: FixtureOffer
});
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(signinButton).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
expect(offerName).toBeInTheDocument();
expect(offerDescription).toBeInTheDocument();
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
name: 'Jamie Larsen',
offerId,
plan: planId,
tierId: tier.id,
cadence: 'month'
});
window.location.hash = '';
});
test('to an offer via link with portal disabled', async () => {
let site = {
...FixtureSite.multipleTiers.basic,
portal_button: false
};
window.location.hash = `#/portal/offers/${FixtureOffer.id}`;
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site,
offer: FixtureOffer
});
const singleTier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).not.toBeInTheDocument();
expect(siteTitle).not.toBeInTheDocument();
expect(emailInput).not.toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(signinButton).not.toBeInTheDocument();
expect(submitButton).not.toBeInTheDocument();
expect(offerName).not.toBeInTheDocument();
expect(offerDescription).not.toBeInTheDocument();
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: undefined,
name: undefined,
offerId: offerId,
plan: planId,
tierId: singleTier.id,
cadence: 'month'
});
window.location.hash = '';
});
});
});

View File

@ -0,0 +1,429 @@
import React from 'react';
import App from '../App.js';
import {fireEvent, appRender, within} from '../utils/test-utils';
import {offer as FixtureOffer, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
const offerSetup = async ({site, member = null, offer}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.site.offer = jest.fn(() => {
return Promise.resolve({
offers: [offer]
});
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const popupFrame = await utils.findByTitle(/portal-popup/i);
const triggerButtonFrame = utils.queryByTitle(/portal-trigger/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const chooseBtns = within(popupIframeDocument).queryAllByRole('button', {name: 'Choose'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const offerName = within(popupIframeDocument).queryByText(offer.display_title);
const offerDescription = within(popupIframeDocument).queryByText(offer.display_description);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
chooseBtns,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
offerName,
offerDescription,
...utils
};
};
const setup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
const accountHomeTitle = within(popupIframeDocument).queryByText('Your account');
const viewPlansButton = within(popupIframeDocument).queryByRole('button', {name: 'View plans'});
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
accountHomeTitle,
viewPlansButton,
...utils
};
};
const multiTierSetup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member
});
});
ghostApi.member.sendMagicLink = jest.fn(() => {
return Promise.resolve('success');
});
ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve();
});
const utils = appRender(
<App api={ghostApi} />
);
const freeTierDescription = site.products?.find(p => p.type === 'free')?.description;
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryAllByText(/free$/i);
const freePlanDescription = within(popupIframeDocument).queryAllByText(freeTierDescription);
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
const accountHomeTitle = within(popupIframeDocument).queryByText('Your account');
const viewPlansButton = within(popupIframeDocument).queryByRole('button', {name: 'View plans'});
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
freePlanDescription,
accountHomeTitle,
viewPlansButton,
...utils
};
};
describe('Logged-in free member', () => {
describe('can upgrade on single tier site', () => {
test('with default settings on monthly plan', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame,
popupIframeDocument, accountHomeTitle, viewPlansButton
} = await setup({
site: FixtureSite.singleTier.basic,
member: FixtureMember.free
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(viewPlansButton).toBeInTheDocument();
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
fireEvent.click(viewPlansButton);
const monthlyPlanContainer = await within(popupIframeDocument).findByText('Monthly');
fireEvent.click(monthlyPlanContainer);
// added fake timeout for react state delay in setting plan
await new Promise((r) => {
setTimeout(r, 10);
});
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
metadata: {
checkoutType: 'upgrade'
},
offerId: undefined,
plan: singleTierProduct.monthlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'month'
});
});
test('with default settings on yearly plan', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame,
popupIframeDocument, accountHomeTitle, viewPlansButton
} = await setup({
site: FixtureSite.singleTier.basic,
member: FixtureMember.free
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(viewPlansButton).toBeInTheDocument();
const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
fireEvent.click(viewPlansButton);
await within(popupIframeDocument).findByText('Monthly');
const yearlyPlanContainer = await within(popupIframeDocument).findByText('Yearly');
fireEvent.click(yearlyPlanContainer);
// added fake timeout for react state delay in setting plan
await new Promise((r) => {
setTimeout(r, 10);
});
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
metadata: {
checkoutType: 'upgrade'
},
offerId: undefined,
plan: singleTierProduct.yearlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'year'
});
});
test('to an offer via link', async () => {
window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site: FixtureSite.singleTier.basic,
member: FixtureMember.altFree,
offer: FixtureOffer
});
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(signinButton).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
expect(offerName).toBeInTheDocument();
expect(offerDescription).toBeInTheDocument();
expect(emailInput).toHaveValue('jimmie@example.com');
expect(nameInput).toHaveValue('Jimmie Larson');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jimmie@example.com',
name: 'Jimmie Larson',
offerId,
plan: planId,
tierId: singleTierProduct.id,
cadence: 'month'
});
window.location.hash = '';
});
test('to an offer via link with portal disabled', async () => {
let site = {
...FixtureSite.singleTier.basic,
portal_button: false
};
window.location.hash = `#/portal/offers/${FixtureOffer.id}`;
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site: site,
member: FixtureMember.altFree,
offer: FixtureOffer
});
let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).not.toBeInTheDocument();
expect(siteTitle).not.toBeInTheDocument();
expect(emailInput).not.toBeInTheDocument();
expect(nameInput).not.toBeInTheDocument();
expect(signinButton).not.toBeInTheDocument();
expect(submitButton).not.toBeInTheDocument();
expect(offerName).not.toBeInTheDocument();
expect(offerDescription).not.toBeInTheDocument();
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
metadata: {
checkoutType: 'upgrade'
},
offerId: offerId,
plan: planId,
tierId: singleTierProduct.id,
cadence: 'month'
});
window.location.hash = '';
});
});
});
describe('Logged-in free member', () => {
describe('can upgrade on multi tier site', () => {
test('with default settings', async () => {
const {
ghostApi, popupFrame, triggerButtonFrame,
popupIframeDocument, accountHomeTitle, viewPlansButton
} = await multiTierSetup({
site: FixtureSite.multipleTiers.basic,
member: FixtureMember.free
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(viewPlansButton).toBeInTheDocument();
const singleTierProduct = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid');
fireEvent.click(viewPlansButton);
await within(popupIframeDocument).findByText('Monthly');
// allow default checkbox switch to yearly
await new Promise((r) => {
setTimeout(r, 10);
});
const chooseBtns = within(popupIframeDocument).queryAllByRole('button', {name: 'Choose'});
fireEvent.click(chooseBtns[0]);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
metadata: {
checkoutType: 'upgrade'
},
offerId: undefined,
plan: singleTierProduct.yearlyPrice.id,
tierId: singleTierProduct.id,
cadence: 'year'
});
});
test('to an offer via link', async () => {
window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
const {
ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
siteTitle,
offerName, offerDescription
} = await offerSetup({
site: FixtureSite.multipleTiers.basic,
member: FixtureMember.altFree,
offer: FixtureOffer
});
let planId = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
let singleTierProduct = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid');
let offerId = FixtureOffer.id;
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(siteTitle).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(signinButton).not.toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
expect(offerName).toBeInTheDocument();
expect(offerDescription).toBeInTheDocument();
expect(emailInput).toHaveValue('jimmie@example.com');
expect(nameInput).toHaveValue('Jimmie Larson');
fireEvent.click(submitButton);
expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
email: 'jimmie@example.com',
name: 'Jimmie Larson',
offerId,
plan: planId,
tierId: singleTierProduct.id,
cadence: 'month'
});
window.location.hash = '';
});
});
});

Some files were not shown because too many files have changed in this diff Show More