mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Updated signup page for members (#10493)
no issue * Added new subscribe page with stripe integration
This commit is contained in:
parent
464caaf5df
commit
beeedf7005
11 changed files with 339 additions and 20 deletions
18
ghost/members-api/static/auth/components/CheckoutForm.js
Normal file
18
ghost/members-api/static/auth/components/CheckoutForm.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import React, {Component} from 'react';
|
||||
import {CardElement} from 'react-stripe-elements';
|
||||
|
||||
class CheckoutForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="gm-form-element">
|
||||
<CardElement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutForm;
|
|
@ -28,7 +28,7 @@ export default class Form extends Component {
|
|||
|
||||
wrapChildren(children, data, onInput = () => {}) {
|
||||
return children.map(child => {
|
||||
const { bindTo } = child.attributes;
|
||||
const { bindTo } = child.attributes || {};
|
||||
if (bindTo) {
|
||||
child.attributes.value = data[bindTo];
|
||||
child.attributes.onInput = (e) => {
|
||||
|
|
|
@ -6,6 +6,9 @@ export default class MembersProvider extends Component {
|
|||
constructor() {
|
||||
super();
|
||||
this.setGatewayFrame = gatewayFrame => this.gatewayFrame = gatewayFrame;
|
||||
this.ready = new Promise((resolve) => {
|
||||
this.setReady = resolve;
|
||||
})
|
||||
this.gateway = null;
|
||||
}
|
||||
|
||||
|
@ -16,7 +19,8 @@ export default class MembersProvider extends Component {
|
|||
signin: this.createMethod('signin'),
|
||||
signup: this.createMethod('signup'),
|
||||
requestPasswordReset: this.createMethod('requestPasswordReset'),
|
||||
resetPassword: this.createMethod('resetPassword')
|
||||
resetPassword: this.createMethod('resetPassword'),
|
||||
getConfig: this.createMethod('getConfig'),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -35,19 +39,23 @@ export default class MembersProvider extends Component {
|
|||
const gatewayFrame = this.gatewayFrame;
|
||||
gatewayFrame.addEventListener('load', () => {
|
||||
this.gateway = layer0(gatewayFrame)
|
||||
this.setReady();
|
||||
});
|
||||
}
|
||||
|
||||
createMethod(method) {
|
||||
return (options) => {
|
||||
return new Promise((resolve, reject) =>
|
||||
this.gateway.call(method, options, (err, successful) => {
|
||||
if (err || !successful) {
|
||||
reject(err || !successful);
|
||||
}
|
||||
resolve(successful);
|
||||
})
|
||||
);
|
||||
return this.ready.then(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.gateway.call(method, options, (err, successful) => {
|
||||
console.log({method, options, err, successful});
|
||||
if (err || !successful) {
|
||||
reject(err || !successful);
|
||||
}
|
||||
resolve(successful);
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import SignupPage from '../pages/SignupPage';
|
|||
import RequestPasswordResetPage from '../pages/RequestPasswordResetPage';
|
||||
import PasswordResetSentPage from '../pages/PasswordResetSentPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import StripePaymentPage from '../pages/StripePaymentPage';
|
||||
|
||||
export default class Modal extends Component {
|
||||
constructor(props, context) {
|
||||
|
@ -17,6 +18,21 @@ export default class Modal extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
if (this.state.loadingConfig) {
|
||||
return;
|
||||
}
|
||||
this.context.members.getConfig().then(paymentConfig => {
|
||||
this.setState({paymentConfig, loadingConfig: false});
|
||||
}).catch((error) => {
|
||||
this.setState({error, loadingConfig: false});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
handleAction(promise) {
|
||||
promise.then((success) => {
|
||||
this.close(success);
|
||||
|
@ -25,9 +41,29 @@ export default class Modal extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
renderSignupPage(state) {
|
||||
const { error, paymentConfig } = state;
|
||||
const { members } = this.context;
|
||||
const signup = (data) => this.handleAction(members.signup(data));
|
||||
const closeModal = () => this.close();
|
||||
const createAccountWithSubscription = (data) => this.handleAction(
|
||||
members.signup(data).then(() => {
|
||||
members.createSubscription(data);
|
||||
})
|
||||
);
|
||||
const stripeConfig = paymentConfig && paymentConfig.find(({adapter}) => adapter === 'stripe');
|
||||
if (stripeConfig) {
|
||||
return <StripePaymentPage stripeConfig={stripeConfig} error={error} hash="signup" handleSubmit={createAccountWithSubscription} handleClose={closeModal}/>
|
||||
|
||||
}
|
||||
return (
|
||||
<SignupPage error={error} hash="signup" handleSubmit={signup} handleClose={closeModal}/>
|
||||
)
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { queryToken } = props;
|
||||
const { containerClass, error } = state;
|
||||
const { containerClass, error, loadingConfig, paymentConfig } = state;
|
||||
const { members } = this.context;
|
||||
|
||||
const closeModal = () => this.close();
|
||||
|
@ -38,11 +74,18 @@ export default class Modal extends Component {
|
|||
const requestReset = (data) => this.handleAction(members.requestPasswordReset(data));
|
||||
const resetPassword = (data) => this.handleAction(members.resetPassword(data));
|
||||
|
||||
if (loadingConfig) {
|
||||
return (
|
||||
<Pages className={containerClass} onChange={clearError} onClick={closeModal}>
|
||||
Loading...
|
||||
</Pages>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Pages className={containerClass} onChange={clearError} onClick={closeModal}>
|
||||
<SigninPage error={error} hash="" handleSubmit={signup} handleClose={closeModal}/>
|
||||
<SigninPage error={error} hash="signin" handleSubmit={signin} handleClose={closeModal}/>
|
||||
<SignupPage error={error} hash="signup" handleSubmit={signup} handleClose={closeModal}/>
|
||||
{this.renderSignupPage(state)}
|
||||
<RequestPasswordResetPage error={error} hash="request-password-reset" handleSubmit={requestReset} handleClose={closeModal}/>
|
||||
<PasswordResetSentPage error={error} hash="password-reset-sent" handleSubmit={requestReset} handleClose={closeModal}/>
|
||||
<ResetPasswordPage error={error} hash="reset-password" handleSubmit={resetPassword} handleClose={closeModal}/>
|
||||
|
|
32
ghost/members-api/static/auth/index.html
Normal file
32
ghost/members-api/static/auth/index.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="<%= htmlWebpackPlugin.files.publicPath %>manifest.json">
|
||||
<% if (htmlWebpackPlugin.options.manifest.theme_color) { %>
|
||||
<meta name="theme-color" content="<%= htmlWebpackPlugin.options.manifest.theme_color %>">
|
||||
<% } %>
|
||||
<% for (var chunk of webpack.chunks) { %>
|
||||
<% if (chunk.names.length === 1 && chunk.names[0] === 'polyfills') continue; %>
|
||||
<% for (var file of chunk.files) { %>
|
||||
<% if (htmlWebpackPlugin.options.preload && file.match(/\.(js|css)$/)) { %>
|
||||
<link rel="preload" href="<%= htmlWebpackPlugin.files.publicPath + file %>" as="<%= file.match(/\.css$/)?'style':'script' %>">
|
||||
<% } else if (file.match(/manifest\.json$/)) { %>
|
||||
<link rel="manifest" href="<%= htmlWebpackPlugin.files.publicPath + file %>">
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
</head>
|
||||
<body>
|
||||
<%= htmlWebpackPlugin.options.ssr({
|
||||
url: '/'
|
||||
}) %>
|
||||
<script defer src="<%= htmlWebpackPlugin.files.chunks['bundle'].entry %>"></script>
|
||||
<script>window.fetch||document.write('<script src="<%= htmlWebpackPlugin.files.chunks["polyfills"].entry %>"><\/script>')</script>
|
||||
</body>
|
||||
</html>
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "preact build --src=index.js --dest=dist --service-worker=false --no-prerender",
|
||||
"build": "preact build --template=index.html --src=index.js --dest=dist --service-worker=false --no-prerender",
|
||||
"dev": "yarn build --no-production && preact watch --port=8080",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
|
@ -23,6 +23,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"preact": "^8.2.1",
|
||||
"preact-compat": "^3.17.0"
|
||||
"preact-compat": "^3.17.0",
|
||||
"react-stripe-elements": "^2.0.3"
|
||||
}
|
||||
}
|
||||
|
|
146
ghost/members-api/static/auth/pages/StripePaymentPage.js
Normal file
146
ghost/members-api/static/auth/pages/StripePaymentPage.js
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { Elements, StripeProvider, injectStripe } from 'react-stripe-elements';
|
||||
import { Component } from 'react';
|
||||
import FormHeader from '../components/FormHeader';
|
||||
import FormSubmit from '../components/FormSubmit';
|
||||
import FormHeaderCTA from '../components/FormHeaderCTA';
|
||||
import { IconClose } from '../components/icons';
|
||||
import NameInput from '../components/NameInput';
|
||||
import EmailInput from '../components/EmailInput';
|
||||
import PasswordInput from '../components/PasswordInput';
|
||||
import CheckoutForm from '../components/CheckoutForm';
|
||||
import Form from '../components/Form';
|
||||
|
||||
|
||||
class PaymentForm extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
handleSubmit = ({ name, email, password, plan }) => {
|
||||
// Within the context of `Elements`, this call to createToken knows which Element to
|
||||
// tokenize, since there's only one in this group.
|
||||
plan = this.props.selectedPlan ? this.props.selectedPlan.name : "";
|
||||
this.props.stripe.createToken({ name: name }).then(({ token }) => {
|
||||
this.props.handleSubmit({
|
||||
adapter: 'stripe',
|
||||
plan: plan,
|
||||
stripeToken: token.id,
|
||||
name, email, password
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Form bindTo="request-password-reset" onSubmit={(data) => this.handleSubmit(data)}>
|
||||
<NameInput bindTo="name" />
|
||||
<EmailInput bindTo="email" />
|
||||
<PasswordInput bindTo="password" />
|
||||
<CheckoutForm />
|
||||
|
||||
<FormSubmit label="Confirm Payment" />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const PaymentFormWrapped = injectStripe(PaymentForm);
|
||||
|
||||
export default class StripePaymentPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.plans = props.stripeConfig.config.plans || [];
|
||||
this.state = {
|
||||
selectedPlan: this.plans[0] ? this.plans[0] : ""
|
||||
}
|
||||
}
|
||||
|
||||
renderPlan({ currency, amount, id, interval, name }) {
|
||||
let planStyle = {
|
||||
padding: "12px",
|
||||
border: "1px solid #e2e8ed",
|
||||
borderRadius: "6px",
|
||||
marginBottom: "12px",
|
||||
marginTop: "12px",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: "200px"
|
||||
};
|
||||
const selectedPlanId = this.state.selectedPlan ? this.state.selectedPlan.id : "";
|
||||
return (
|
||||
<div style={planStyle}>
|
||||
<input type="radio" id={id} name="radio-group" value={id} defaultChecked={id === selectedPlanId} />
|
||||
<label for={id}>
|
||||
<span style={{fontSize: "24px", marginLeft: "9px"}}> {`$${amount}`}</span>
|
||||
<span style={{padding: "0px 1px", color: "#77919c"}}> / </span>
|
||||
<span style={{color: "#77919c"}}> {`${interval}`}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
changePlan(e) {
|
||||
const plan = this.plans.find(plan => plan.id === e.target.value);
|
||||
this.setState({
|
||||
selectedPlan: plan
|
||||
})
|
||||
}
|
||||
|
||||
renderPlans(plans) {
|
||||
return (
|
||||
<div onChange={(e) => this.changePlan(e)}>
|
||||
{
|
||||
plans.map((plan) => this.renderPlan(plan))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderPlansSection() {
|
||||
const separatorStyle = {
|
||||
height: "1px",
|
||||
borderTop: "2px solid #e7f0f6",
|
||||
width: "180px",
|
||||
margin: "12px 0"
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: "20px", width: "295px", display: "flex", justifyContent: "center", alignItems: "center", flexDirection: "column", background: "#fcfdfd" }}>
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
<div className="gm-logo"></div>
|
||||
<div style={{display: "flex", flexDirection: "column", paddingLeft: "12px"}}>
|
||||
<span style={{fontSize: "16px", fontWeight: "bold"}}> The Blueprint</span>
|
||||
<span style={{fontSize: "14px", color: "#9cb2bc", marginTop: "3px"}}> Subscription</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="separator" style={separatorStyle}> </div>
|
||||
{this.renderPlans(this.plans)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render({ error, handleClose, handleSubmit, stripeConfig }) {
|
||||
const publicKey = stripeConfig.config.publicKey || '';
|
||||
return (
|
||||
<div className="gm-modal-container">
|
||||
<div className="gm-modal gm-auth-modal gm-subscribe-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<a className="gm-modal-close" onClick={handleClose}>{IconClose}</a>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ width: "300px", padding: "20px" }}>
|
||||
<FormHeader title="Subscribe" error={error} errorText="Unable to confirm payment">
|
||||
<FormHeaderCTA title="Already a member?" label="Log in" hash="#signin" />
|
||||
</FormHeader>
|
||||
<StripeProvider apiKey={publicKey}>
|
||||
<Elements>
|
||||
<PaymentFormWrapped handleSubmit={handleSubmit} publicKey={publicKey} selectedPlan={this.state.selectedPlan} />
|
||||
</Elements>
|
||||
</StripeProvider>
|
||||
</div>
|
||||
<div style={{ border: "1px solid black" }}></div>
|
||||
{this.renderPlansSection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
/* Reusable components */
|
||||
/* ------------------------------------------------------------
|
||||
/* ------------------------------------------------------------
|
||||
|
||||
|
||||
*/
|
||||
|
@ -41,6 +41,10 @@
|
|||
animation: openModal 0.6s ease 1 forwards;
|
||||
}
|
||||
|
||||
.gm-subscribe-modal {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.gm-modal-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
@ -108,7 +112,7 @@
|
|||
transform: none;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
.gm-modal {
|
||||
width: calc(100% - 48px);
|
||||
height: calc(100vh - 48px);
|
||||
|
@ -209,7 +213,7 @@ button:hover {
|
|||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active,
|
||||
textarea:-webkit-autofill,
|
||||
|
@ -392,4 +396,44 @@ select:-webkit-autofill:active {
|
|||
|
||||
.gm-form-errortext i {
|
||||
margin: 1px 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS shown here will not be introduced in the Quickstart guide, but shows
|
||||
* how you can use CSS to style your Element's container.
|
||||
*/
|
||||
.StripeElement {
|
||||
font-size: var(--text-s);
|
||||
color: var(--grey-d3);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--grey-l1);
|
||||
height: 38px;
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--white);
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border var(--animation-speed-f1) ease-in-out;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.StripeElement:hover {
|
||||
border: 1px solid var(--grey);
|
||||
}
|
||||
|
||||
.StripeElement--focus {
|
||||
border: 1px solid color-mod(var(--blue) a(0.8));
|
||||
box-shadow: 0 0 6px rgba(62, 176, 239, 0.3), 0 0 0px 40px #FFF inset;
|
||||
}
|
||||
|
||||
.StripeElement--invalid {
|
||||
border: 1px solid color-mod(var(--red) a(0.8));
|
||||
background: color-mod(var(--red) a(0.02))
|
||||
}
|
||||
|
||||
.StripeElement--webkit-autofill {
|
||||
background-color: #fefde5 !important;
|
||||
}
|
|
@ -6823,7 +6823,7 @@ promise-polyfill@^6.0.2:
|
|||
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.1.0.tgz#dfa96943ea9c121fca4de9b5868cb39d3472e057"
|
||||
integrity sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc=
|
||||
|
||||
prop-types@^15.6.2:
|
||||
prop-types@^15.5.10, prop-types@^15.6.2:
|
||||
version "15.6.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
|
||||
integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
|
||||
|
@ -6980,6 +6980,13 @@ rc@^1.0.1, rc@^1.1.2, rc@^1.1.6, rc@^1.2.7:
|
|||
minimist "^1.2.0"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
react-stripe-elements@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/react-stripe-elements/-/react-stripe-elements-2.0.3.tgz#cfd0f68d00ce52a07aab1cb2b59b29dc12309486"
|
||||
integrity sha512-aKLiWyfP0n3Gq42BKykULgoruNVRXEaeYh8NSokdgH3ubGU3nsHFZJg3LgbT/XOquttDGHE7kLhleaX+UnN81A==
|
||||
dependencies:
|
||||
prop-types "^15.5.10"
|
||||
|
||||
read-all-stream@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
|
||||
|
|
|
@ -60,6 +60,26 @@
|
|||
|
||||
addMethod('getToken', getToken);
|
||||
|
||||
addMethod('createSubscription', function createSubscription({adapter, plan, stripeToken}) {
|
||||
return fetch(`${membersApi}/subscription`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
origin,
|
||||
adapter,
|
||||
plan,
|
||||
stripeToken
|
||||
})
|
||||
}).then((res) => {
|
||||
if (res.ok) {
|
||||
storage.setItem('signedin', true);
|
||||
}
|
||||
return res.ok;
|
||||
});
|
||||
});
|
||||
|
||||
addMethod('signin', function signin({email, password}) {
|
||||
return fetch(`${membersApi}/signin`, {
|
||||
method: 'POST',
|
||||
|
|
|
@ -45,7 +45,7 @@ module.exports = class StripePaymentProcessor {
|
|||
return {
|
||||
adapter: 'stripe',
|
||||
config: {
|
||||
public_token: this._public_token,
|
||||
publicKey: this._public_token,
|
||||
plans: this._plans.map(({id, currency, amount, interval, nickname}) => ({
|
||||
id, currency, amount, interval,
|
||||
name: nickname
|
||||
|
|
Loading…
Add table
Reference in a new issue