0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Refactored auth pages for future flows (#10458)

no-issue

* Used camelCase for gateway method calls
* Added some components for building blocks of forms
* Added input specific components
* Added Form component
    This handles collecting the data to submit and sharing state between forms
* Added Pages component to handle urls
* Added the pages for the popup
* Added MembersProvider component
    This is designed to give its children access to gateway methods
* Added Modal component
    This wraps the pages and handles dispatching form submissions to the members gateway
* Refactored index.js to use new components/pages
* Fixed default page from Signup -> Signin
This commit is contained in:
Fabien O'Carroll 2019-02-07 10:39:55 +01:00
parent 0755036926
commit b1a1f61d5d
18 changed files with 488 additions and 433 deletions

View file

@ -0,0 +1,18 @@
import FormInput from './FormInput';
import { IconEmail } from './icons';
export default ({value, error, children, onInput, className}) => (
<FormInput
type="email"
name="email"
label="Email"
value={value}
error={error}
icon={IconEmail}
placeholder="Email..."
required={true}
className={className}
onInput={onInput}>
{children}
</FormInput>
);

View file

@ -0,0 +1,82 @@
import { Component } from 'preact';
const states = {};
export default class Form extends Component {
constructor(props) {
super(props);
const includeData = props.includeData || {};
if (props.bindTo) {
if (states[props.bindTo]) {
this.state = states[props.bindTo]
this.state.data = { ...this.state.data, ...includeData };
} else {
states[props.bindTo] = this.state = {
submitted: false,
data: {...includeData}
}
}
} else {
this.state = {
submitted: false,
data: {...includeData}
}
}
}
wrapChildren(children, data, onInput = () => {}) {
return children.map(child => {
const { bindTo } = child.attributes;
if (bindTo) {
child.attributes.value = data[bindTo];
child.attributes.onInput = (e) => {
// This is a hack
// Preact keeps copy of the child attributes to know whether to rerender
// The state change below will for a check, and the old attributes will be reused
child.attributes.error = false;
this.setState({
submitted: false,
data: Object.assign({}, this.state.data, {
[bindTo]: e.target.value
})
});
}
if (this.state.submitted && !data[bindTo]) {
child.attributes.error = true;
}
}
return child;
})
}
wrapSubmit(onSubmit = () => {}, children, data) {
return (e) => {
e.preventDefault();
const requiredFields = children.map(c => c.attributes.bindTo).filter(x => !!x)
if (!requiredFields.some(x => !data[x])) {
onSubmit(this.state.data)
}
this.setState({
submitted: true
});
}
}
render({bindTo, children, onInput, onSubmit}, state) {
if (bindTo) {
states[bindTo] = state;
}
const data = state.data;
return (
<div className="flex flex-column mt3">
<form className="gm-signup-form" onSubmit={this.wrapSubmit(onSubmit, children, data)} noValidate>
{ this.wrapChildren(children, data, onInput) }
</form>
</div>
);
}
}

View file

@ -0,0 +1,10 @@
export default ({title, hash, label}) => (
<div className="gm-auth-footer">
<div className="flex items-baseline">
<h4>{ title }</h4>
<a href={hash}>
{ label }
</a>
</div>
</div>
);

View file

@ -0,0 +1,16 @@
import { IconError } from './icons';
export default ({title, error, errorText, children}) => (
<div>
<div className="gm-logo"></div>
<div className="gm-auth-header">
<h1>{ title }</h1>
{ children }
</div>
{(error ?
<div class="gm-form-errortext"><i>{ IconError }</i>
<span> {errorText} </span>
</div> : "")
}
</div>
);

View file

@ -0,0 +1,8 @@
export default ({title, label, hash}) => (
<div className="flex items-baseline mt2">
<h4>{ title }</h4>
<a href={hash}>
{ label }
</a>
</div>
);

View file

@ -0,0 +1,23 @@
export default ({type, name, placeholder, value = '', error, onInput, required, className, children, icon}) => (
<div className="gm-form-element">
<div className="gm-input">
<input
type={ type }
name={ name }
key={ name }
placeholder={ placeholder }
value={ value }
onInput={ (e) => onInput(e, name) }
required={ required }
className={[
(value ? "gm-input-filled" : ""),
(error ? "gm-error" : ""),
className
].join(' ')}
/>
<label for={ name }>{ placeholder }</label>
<i>{ icon }</i>
{ children }
</div>
</div>
);

View file

@ -0,0 +1,7 @@
export default ({onClick, label}) => (
<div className="mt6">
<button type="submit" className="gm-btn-blue" onClick={onClick}>
{ label }
</button>
</div>
);

View file

@ -0,0 +1,54 @@
import { Component } from 'preact';
const layer0 = require('../layer0');
export default class MembersProvider extends Component {
constructor() {
super();
this.setGatewayFrame = gatewayFrame => this.gatewayFrame = gatewayFrame;
this.gateway = null;
}
getChildContext() {
return {
members: {
createSubscription: this.createMethod('createSubscription'),
signin: this.createMethod('signin'),
signup: this.createMethod('signup'),
requestPasswordReset: this.createMethod('requestPasswordReset'),
resetPassword: this.createMethod('resetPassword')
}
};
}
render({apiUrl, children}) {
const src = `${apiUrl}/members/gateway`;
return (
<div>
{ children }
<iframe src={src} ref={this.setGatewayFrame} id="members-gateway" style="display: none;"/>
</div>
);
}
componentDidMount() {
const gatewayFrame = this.gatewayFrame;
gatewayFrame.addEventListener('load', () => {
this.gateway = layer0(gatewayFrame)
});
}
createMethod(method) {
return (options) => {
return new Promise((resolve, reject) =>
this.gateway.call(method, options, (err, successful) => {
if (err || !successful) {
reject(err || !successful);
}
resolve(successful);
})
);
}
}
}

View file

@ -0,0 +1,68 @@
import { Component } from 'preact';
import Pages from './Pages';
import SigninPage from '../pages/SigninPage';
import SignupPage from '../pages/SignupPage';
import RequestPasswordResetPage from '../pages/RequestPasswordResetPage';
import PasswordResetSentPage from '../pages/PasswordResetSentPage';
import ResetPasswordPage from '../pages/ResetPasswordPage';
export default class Modal extends Component {
constructor(props, context) {
super();
this.state = {
error: null,
containerClass: 'gm-page-overlay'
}
}
handleAction(promise) {
promise.then((success) => {
this.close(success);
}, (error) => {
this.setState({error});
});
}
render(props, state) {
const { queryToken } = props;
const { containerClass, error } = state;
const { members } = this.context;
const closeModal = () => this.close();
const clearError = () => this.setState({error: null});
const signin = (data) => this.handleAction(members.signin(data));
const signup = (data) => this.handleAction(members.signup(data));
const requestReset = (data) => this.handleAction(members.requestPasswordReset(data));
const resetPassword = (data) => this.handleAction(members.resetPassword(data));
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}/>
<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}/>
</Pages>
);
}
close(success) {
this.setState({
containerClass: 'gm-page-overlay close'
});
window.setTimeout(() => {
this.setState({
containerClass: 'gm-page-overlay'
});
window.parent.postMessage({
msg: 'pls-close-auth-popup',
success
}, '*');
}, 700);
}
}

View file

@ -0,0 +1,18 @@
import FormInput from './FormInput';
import { IconName } from './icons';
export default ({value, error, children, onInput, className}) => (
<FormInput
type="text"
name="name"
label="Name"
value={value}
error={error}
icon={IconName}
placeholder="Name..."
required={true}
className={className}
onInput={onInput}>
{children}
</FormInput>
);

View file

@ -0,0 +1,41 @@
import { Component } from 'preact';
export default class Pages extends Component {
constructor(props) {
super(props);
this.state = this.getStateFromBrowser();
window.addEventListener("hashchange", () => this.onHashChange(), false);
this.handleChange = props.onChange || (() => {});
}
getStateFromBrowser() {
const [fullMatch, hash, query] = window.location.hash.match(/^#([^?]+)\??(.*)$/) || ['#', '', ''];
return {
hash,
query,
fullMatch
};
}
onHashChange() {
this.setState(this.getStateFromBrowser());
this.handleChange();
}
filterChildren(children, state) {
return children.filter((child) => {
return child.attributes.hash === state.hash;
}).map((child) => {
child.attributes.frameLocation = { ...this.state };
return child;
});
}
render({children, className, onClick}, state) {
return (
<div className={className} onClick={onClick}>
{ this.filterChildren(children, state) }
</div>
);
}
}

View file

@ -0,0 +1,18 @@
import FormInput from './FormInput';
import { IconLock } from './icons';
export default ({value, error, children, onInput, className}) => (
<FormInput
type="password"
name="password"
label="Password"
value={value}
error={error}
icon={IconLock}
placeholder="Password..."
required={true}
className={className}
onInput={onInput}>
{ children }
</FormInput>
);

View file

@ -1,449 +1,24 @@
import './styles/members.css';
import {IconEmail, IconLock, IconName, IconClose, IconError} from './components/icons';
import { Component } from 'preact';
const origin = new URL(window.location).origin;
const membersApi = location.pathname.replace(/\/members\/auth\/?$/, '/ghost/api/v2/members');
const storage = window.localStorage;
var layer0 = require('./layer0');
function getFreshState() {
const [hash, formType, query] = window.location.hash.match(/^#([^?]+)\??(.*)$/) || ['#signin?', 'signin', ''];
return {
formData: {},
query,
formType,
parentContainerClass: 'gm-page-overlay',
showError: false,
submitFail: false
};
}
import MembersProvider from './components/MembersProvider';
import Modal from './components/Modal';
export default class App extends Component {
constructor() {
super();
this.state = getFreshState();
this.gatewayFrame = '';
window.addEventListener("hashchange", () => this.onHashChange(), false);
}
const apiUrl = window.location.href.substring(0, window.location.href.indexOf('/members/auth'));
loadGateway() {
const blogUrl = window.location.href.substring(0, window.location.href.indexOf('/members/auth'));
const frame = window.document.createElement('iframe');
frame.id = 'member-gateway';
frame.style.display = 'none';
frame.src = `${blogUrl}/members/gateway`;
frame.onload = () => {
this.gatewayFrame = layer0(frame);
this.state = {
apiUrl
};
document.body.appendChild(frame);
}
componentDidMount() {
this.loadGateway();
}
onHashChange() {
this.setState(getFreshState());
}
onInputChange(e, name) {
let value = e.target.value;
this.setState({
formData: {
...this.state.formData,
[name]: value
}
});
}
submitForm(e) {
e.preventDefault();
if (this.hasFrontendError(this.state.formType)) {
return false;
}
switch (this.state.formType) {
case 'signin':
this.signin(this.state.formData);
break;
case 'signup':
this.signup(this.state.formData);
break;
case 'request-password-reset':
this.requestPasswordReset(this.state.formData);
break;
case 'password-reset-sent':
this.resendPasswordResetEmail(this.state.formData)
break;
case 'reset-password':
this.resetPassword(this.state.formData)
break;
}
return false;
}
signin({ email, password }) {
this.gatewayFrame.call('signin', {email, password}, (err, successful) => {
if (err || !successful) {
this.setState({
submitFail: true
});
}
});
}
signup({ name, email, password }) {
this.gatewayFrame.call('signup', { name, email, password }, (err, successful) => {
if (err || !successful) {
this.setState({
submitFail: true
});
}
});
}
requestPasswordReset({ email }) {
this.gatewayFrame.call('request-password-reset', {email}, (err, successful) => {
if (err || !successful) {
this.setState({
submitFail: true
});
} else {
window.location.hash = 'password-reset-sent';
}
});
}
resendPasswordResetEmail({ email }) {
this.gatewayFrame.call('request-password-reset', {email}, (err, successful) => {
if (err || !successful) {
this.setState({
submitFail: true
});
} else {
window.location.hash = 'password-reset-sent';
}
});
}
resetPassword({ password }) {
const queryParams = new URLSearchParams(this.state.query);
const token = queryParams.get('token') || '';
this.gatewayFrame.call('reset-password', {password, token}, (err, successful) => {
if (err || !successful) {
this.setState({
submitFail: true
});
}
});
}
hasFrontendError(formType = this.state.formType) {
switch(formType) {
case 'signin':
return (
this.hasError({errorType: 'no-input', data: 'email'}) ||
this.hasError({errorType: 'no-input', data: 'password'})
);
case 'signup':
return (
this.hasError({errorType: 'no-input', data: 'email'}) ||
this.hasError({errorType: 'no-input', data: 'password'}) ||
this.hasError({errorType: 'no-input', data: 'name'})
);
case 'request-password-reset':
return (
this.hasError({errorType: 'no-input', data: 'email'})
);
case 'reset-password':
return (
this.hasError({errorType: 'no-input', data: 'password'})
);
break;
}
return false;
}
hasError({errorType, data}) {
if (!this.state.showError) {
return false;
}
let value = '';
switch(errorType) {
case 'no-input':
value = this.state.formData[data];
return (!value);
case 'form-submit':
return this.state.submitFail;
}
}
renderError({error, formType}) {
if (this.hasError(error)) {
let errorLabel = '';
switch(error.errorType) {
case 'no-input':
errorLabel = `Enter ${error.data}`;
break;
case 'form-submit':
switch(formType) {
case 'signin':
errorLabel = "Wrong email or password";
break;
case 'signup':
errorLabel = "Email already registered"
break;
case 'request-password-reset':
errorLabel = "Unable to send email"
break;
case 'password-reset-sent':
errorLabel = "Unable to send email"
break;
}
}
return (
<span>{ errorLabel }</span>
)
}
return null;
}
renderFormHeaders(formType) {
let mainTitle = '';
let ctaTitle = '';
let ctaLabel = '';
let hash = '';
switch (formType) {
case 'signup':
mainTitle = 'Sign up';
ctaTitle = 'Already a member?';
ctaLabel = 'Log in';
hash = 'signin';
break;
case 'signin':
mainTitle = 'Log in';
ctaTitle = 'Not a member?';
ctaLabel = 'Sign up';
hash = 'signup';
break;
case 'request-password-reset':
mainTitle = 'Reset password';
break;
case 'password-reset-sent':
mainTitle = 'Reset password';
break;
case 'reset-password':
mainTitle = 'Reset password';
break;
}
let formError = this.renderError({ error: {errorType: "form-submit"}, formType });
return (
<div>
<div className="gm-logo"></div>
<div className="gm-auth-header">
<h1>{ mainTitle }</h1>
{(ctaTitle ?
<div className="flex items-baseline mt2">
<h4>{ ctaTitle }</h4>
<a href="javascript:;"
onClick={ (e) => { window.location.hash = hash } }
>
{ ctaLabel }
</a>
</div>
: "")}
</div>
{(formError ? <div class="gm-form-errortext"><i>{ IconError }</i> { formError }</div> : "")}
</div>
)
}
renderFormFooters(formType) {
let mainTitle = '';
let ctaTitle = '';
let ctaLabel = '';
let hash = '';
switch (formType) {
case 'request-password-reset':
ctaTitle = 'Back to';
ctaLabel = 'log in';
hash = 'signin';
break;
case 'password-reset-sent':
ctaTitle = 'Back to';
ctaLabel = 'log in';
hash = 'signin';
break;
case 'reset-password':
ctaTitle = 'Back to';
ctaLabel = 'log in';
hash = 'signin';
break;
}
if (ctaTitle) {
return (
<div className="gm-auth-footer">
<div className="flex items-baseline">
<h4>{ ctaTitle }</h4>
<a href="javascript:;"
onClick={ (e) => { window.location.hash = hash } }
>
{ ctaLabel }
</a>
</div>
</div>
)
}
}
renderFormInput({type, name, label, icon, placeholder, required, formType}) {
let value = this.state.formData[name];
let className = "";
let forgot = (type === 'password' && formType === 'signin');
let inputError = this.renderError({ error: {errorType: 'no-input', data: name}, formType });
className += (value ? "gm-input-filled" : "") + (forgot ? " gm-forgot-input" : "") + (inputError ? " gm-error" : "");
return (
<div className="gm-form-element">
<div className="gm-input">
<input
type={ type }
name={ name }
key={ name }
placeholder={ placeholder }
value={ value || '' }
onInput={ (e) => this.onInputChange(e, name) }
required = {required}
className={ className }
/>
<label for={ name }> { placeholder }</label>
<i>{ icon }</i>
{ (forgot ? <a href="javascript:;" className="gm-forgot-link" onClick={(e) => {window.location.hash = 'request-password-reset'}}>Forgot</a> : "") }
</div>
{/* { (inputError ? <div class="gm-input-errortext">{ inputError }</div> : "")} */}
</div>
)
}
renderFormText({formType}) {
return (
<div className="gm-reset-sent">
<p>Weve sent a recovery email to your inbox. Follow the link in the email to reset your password.</p>
</div>
)
}
onSubmitClick(e) {
this.setState({
showError: true,
submitFail: false
});
}
renderFormSubmit({buttonLabel, formType}) {
return (
<div className="mt6">
<button type="submit" name={ formType } className="gm-btn-blue" onClick={(e) => this.onSubmitClick(e)}>{ buttonLabel }</button>
</div>
)
}
renderFormSection(formType) {
const emailInput = this.renderFormInput({
type: 'email',
name: 'email',
label: 'Email',
icon: IconEmail,
placeholder: 'Email...',
required: true,
formType: formType
});
const passwordInput = this.renderFormInput({
type: 'password',
name: 'password',
label: 'Password',
icon: IconLock,
placeholder: 'Password...',
required: true,
formType: formType
});
const nameInput = this.renderFormInput({
type: 'text',
name: 'name',
label: 'Name',
icon: IconName,
placeholder: 'Name...',
required: true,
formType: formType
});
const formText = this.renderFormText({formType});
let formElements = [];
let buttonLabel = '';
let formContainerClass = 'flex flex-column'
switch (formType) {
case 'signin':
buttonLabel = 'Log in';
formElements = [emailInput, passwordInput, this.renderFormSubmit({formType, buttonLabel})];
formContainerClass += ' mt3'
break;
case 'signup':
buttonLabel = 'Sign up';
formElements = [nameInput, emailInput, passwordInput, this.renderFormSubmit({formType, buttonLabel})];
formContainerClass += ' mt3'
break;
case 'request-password-reset':
buttonLabel = 'Send reset password instructions';
formElements = [emailInput, this.renderFormSubmit({formType, buttonLabel})];
break;
case 'password-reset-sent':
buttonLabel = 'Resend instructions';
formElements = [formText, this.renderFormSubmit({formType, buttonLabel})];
break;
case 'reset-password':
buttonLabel = 'Set password';
formElements = [passwordInput, this.renderFormSubmit({formType, buttonLabel})];
break;
}
return (
<div className={formContainerClass}>
<form className={ `gm-` + formType + `-form` } onSubmit={(e) => this.submitForm(e)} noValidate>
{ formElements }
</form>
</div>
)
}
renderFormComponent(formType = this.state.formType) {
return (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={ (e) => this.close(e)}>{ IconClose }</a>
{this.renderFormHeaders(formType)}
{this.renderFormSection(formType)}
{this.renderFormFooters(formType)}
</div>
</div>
);
}
render() {
return (
<div className={this.state.parentContainerClass} onClick={(e) => this.close(e)}>
{this.renderFormComponent()}
</div>
<MembersProvider apiUrl={ this.state.apiUrl }>
<Modal />
</MembersProvider>
);
}
close(event) {
this.setState({
parentContainerClass: 'gm-page-overlay close'
});
window.setTimeout(() => {
this.setState({
parentContainerClass: 'gm-page-overlay'
});
window.parent.postMessage('pls-close-auth-popup', '*');
}, 700);
}
}

View file

@ -0,0 +1,18 @@
import FormHeader from '../components/FormHeader';
import FormSubmit from '../components/FormSubmit';
import { IconClose } from '../components/icons';
export default ({error, handleClose, handleSubmit}) => (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={handleClose}>{ IconClose }</a>
<FormHeader title="Sign up" error={error} errorText="Unable to send email"/>
<Form bindTo="request-password-reset" onSubmit={handleSubmit}>
<div className="gm-reset-sent">
<p>Weve sent a recovery email to your inbox. Follow the link in the email to reset your password.</p>
</div>
<FormSubmit label="Resend instructions" />
</Form>
</div>
</div>
);

View file

@ -0,0 +1,19 @@
import FormHeader from '../components/FormHeader';
import EmailInput from '../components/EmailInput';
import FormSubmit from '../components/FormSubmit';
import { IconClose } from '../components/icons';
import Form from '../components/Form';
export default ({error, handleClose, handleSubmit}) => (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={handleClose}>{ IconClose }</a>
<FormHeader title="Reset password" error={error} errorText="Unable to send email"/>
<Form bindTo="request-password-reset" onSubmit={handleSubmit}>
<EmailInput bindTo="email"/>
<FormSubmit label="Send reset password instructions"/>
</Form>
</div>
</div>
);

View file

@ -0,0 +1,25 @@
import FormHeader from '../components/FormHeader';
import PasswordInput from '../components/PasswordInput';
import FormSubmit from '../components/FormSubmit';
import Form from '../components/Form';
import { IconClose } from '../components/icons';
const getTokenData = frameLocation => {
const params = new URLSearchParams(frameLocation.query);
const token = params.get('token') || '';
return { token };
};
export default ({error, frameLocation, handleClose, handleSubmit}) => (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={handleClose}>{ IconClose }</a>
<FormHeader title="Reset password" error={error} errorText="Unable to reset password"/>
<Form includeData={getTokenData(frameLocation)} onSubmit={handleSubmit}>
<PasswordInput bindTo="password" />
<FormSubmit label="Set password" />
</Form>
</div>
</div>
);

View file

@ -0,0 +1,29 @@
import Form from '../components/Form';
import FormHeader from '../components/FormHeader';
import FormHeaderCTA from '../components/FormHeaderCTA';
import FormSubmit from '../components/FormSubmit';
import EmailInput from '../components/EmailInput';
import PasswordInput from '../components/PasswordInput';
import { IconClose } from '../components/icons';
export default ({error, handleClose, handleSubmit}) => (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={handleClose}>{ IconClose }</a>
<FormHeader title="Log in" error={error} errorText="Wrong email or password">
<FormHeaderCTA title="Not a member?" label="Sign up" hash="#signup" />
</FormHeader>
<Form onSubmit={handleSubmit}>
<EmailInput bindTo="email"/>
<PasswordInput bindTo="password" className="gm-forgot-input">
<a href="#request-password-reset" className="gm-forgot-link">
Forgot
</a>
</PasswordInput>
<FormSubmit label="Log in"/>
</Form>
</div>
</div>
);

View file

@ -0,0 +1,26 @@
import Form from '../components/Form';
import FormHeader from '../components/FormHeader';
import FormHeaderCTA from '../components/FormHeaderCTA';
import FormSubmit from '../components/FormSubmit';
import NameInput from '../components/NameInput';
import EmailInput from '../components/EmailInput';
import PasswordInput from '../components/PasswordInput';
import { IconClose } from '../components/icons';
export default ({error, handleClose, handleSubmit}) => (
<div className="gm-modal-container">
<div className="gm-modal gm-auth-modal" onClick={(e) => e.stopPropagation()}>
<a className="gm-modal-close" onClick={handleClose}>{ IconClose }</a>
<FormHeader title="Sign up" error={error} errorText="Email already registered">
<FormHeaderCTA title="Already a member?" label="Log in" hash="#signin" />
</FormHeader>
<Form onSubmit={handleSubmit}>
<NameInput bindTo="name" />
<EmailInput bindTo="email" />
<PasswordInput bindTo="password" />
<FormSubmit label="Sign up" />
</Form>
</div>
</div>
);