diff --git a/ghost/members-api/static/auth/components/CheckoutForm.js b/ghost/members-api/static/auth/components/CheckoutForm.js
new file mode 100644
index 0000000000..a4c857c7ad
--- /dev/null
+++ b/ghost/members-api/static/auth/components/CheckoutForm.js
@@ -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 (
+
+
+
+ );
+ }
+}
+
+export default CheckoutForm;
diff --git a/ghost/members-api/static/auth/components/Form.js b/ghost/members-api/static/auth/components/Form.js
index eb11edbf78..c02661c15e 100644
--- a/ghost/members-api/static/auth/components/Form.js
+++ b/ghost/members-api/static/auth/components/Form.js
@@ -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) => {
diff --git a/ghost/members-api/static/auth/components/MembersProvider.js b/ghost/members-api/static/auth/components/MembersProvider.js
index 0331d3e900..ff8ee58701 100644
--- a/ghost/members-api/static/auth/components/MembersProvider.js
+++ b/ghost/members-api/static/auth/components/MembersProvider.js
@@ -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);
+ })
+ });
+ });
}
}
diff --git a/ghost/members-api/static/auth/components/Modal.js b/ghost/members-api/static/auth/components/Modal.js
index 312cb72091..44d1f83c46 100644
--- a/ghost/members-api/static/auth/components/Modal.js
+++ b/ghost/members-api/static/auth/components/Modal.js
@@ -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
+
+ }
+ return (
+
+ )
+ }
+
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 (
+
+ Loading...
+
+ );
+ }
return (
-
+ {this.renderSignupPage(state)}
diff --git a/ghost/members-api/static/auth/index.html b/ghost/members-api/static/auth/index.html
new file mode 100644
index 0000000000..c8a25b55c8
--- /dev/null
+++ b/ghost/members-api/static/auth/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+ <%= htmlWebpackPlugin.options.title %>
+
+
+
+
+ <% if (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)$/)) { %>
+
+ <% } else if (file.match(/manifest\.json$/)) { %>
+
+ <% } %>
+ <% } %>
+ <% } %>
+
+
+
+ <%= htmlWebpackPlugin.options.ssr({
+ url: '/'
+ }) %>
+
+
+
+
diff --git a/ghost/members-api/static/auth/package.json b/ghost/members-api/static/auth/package.json
index 0cf96937a5..8a5c7200e1 100644
--- a/ghost/members-api/static/auth/package.json
+++ b/ghost/members-api/static/auth/package.json
@@ -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"
}
}
diff --git a/ghost/members-api/static/auth/pages/StripePaymentPage.js b/ghost/members-api/static/auth/pages/StripePaymentPage.js
new file mode 100644
index 0000000000..af807dcf5f
--- /dev/null
+++ b/ghost/members-api/static/auth/pages/StripePaymentPage.js
@@ -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 (
+
+ );
+ }
+}
+
+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 (
+
+
+
+
+ )
+ }
+
+ changePlan(e) {
+ const plan = this.plans.find(plan => plan.id === e.target.value);
+ this.setState({
+ selectedPlan: plan
+ })
+ }
+
+ renderPlans(plans) {
+ return (
+ this.changePlan(e)}>
+ {
+ plans.map((plan) => this.renderPlan(plan))
+ }
+
+ );
+ }
+
+ renderPlansSection() {
+ const separatorStyle = {
+ height: "1px",
+ borderTop: "2px solid #e7f0f6",
+ width: "180px",
+ margin: "12px 0"
+ }
+ return (
+
+
+
+
+ The Blueprint
+ Subscription
+
+
+
+ {this.renderPlans(this.plans)}
+
+ )
+ }
+
+ render({ error, handleClose, handleSubmit, stripeConfig }) {
+ const publicKey = stripeConfig.config.publicKey || '';
+ return (
+
+
e.stopPropagation()}>
+
{IconClose}
+
+
+
+ {this.renderPlansSection()}
+
+
+
+ )
+ }
+};
diff --git a/ghost/members-api/static/auth/styles/components.css b/ghost/members-api/static/auth/styles/components.css
index ad2d76d162..805a858b0d 100644
--- a/ghost/members-api/static/auth/styles/components.css
+++ b/ghost/members-api/static/auth/styles/components.css
@@ -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;
-}
\ No newline at end of file
+}
+
+/**
+ * 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;
+ }
\ No newline at end of file
diff --git a/ghost/members-api/static/auth/yarn.lock b/ghost/members-api/static/auth/yarn.lock
index a63602f948..9813655368 100644
--- a/ghost/members-api/static/auth/yarn.lock
+++ b/ghost/members-api/static/auth/yarn.lock
@@ -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"
diff --git a/ghost/members-api/static/gateway/bundle.js b/ghost/members-api/static/gateway/bundle.js
index 18249cae0e..659159c7ed 100644
--- a/ghost/members-api/static/gateway/bundle.js
+++ b/ghost/members-api/static/gateway/bundle.js
@@ -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',
diff --git a/ghost/members-api/subscriptions/payment-processors/stripe/index.js b/ghost/members-api/subscriptions/payment-processors/stripe/index.js
index 65bac4cde5..9024b55155 100644
--- a/ghost/members-api/subscriptions/payment-processors/stripe/index.js
+++ b/ghost/members-api/subscriptions/payment-processors/stripe/index.js
@@ -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