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 ( +
this.handleSubmit(data)}> + + + + + + + + ); + } +} + +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