feat: Purchase "Create account" & "Sign in" flows and Floating label input (#672)

This commit is contained in:
Aman Harwara
2021-10-19 20:20:42 +05:30
committed by GitHub
parent 7f1dddf2d2
commit f9b15262c7
17 changed files with 1079 additions and 2 deletions

View File

@@ -0,0 +1,68 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { CreateAccount } from './panes/CreateAccount';
import { SignIn } from './panes/SignIn';
import SNLogoFull from '../../svg/ic-sn-logo-full.svg';
type PaneSelectorProps = {
currentPane: PurchaseFlowPane;
} & PurchaseFlowViewProps;
type PurchaseFlowViewProps = {
appState: AppState;
application: WebApplication;
};
const PurchaseFlowPaneSelector: FunctionComponent<PaneSelectorProps> = ({
currentPane,
appState,
application,
}) => {
switch (currentPane) {
case PurchaseFlowPane.CreateAccount:
return <CreateAccount appState={appState} application={application} />;
case PurchaseFlowPane.SignIn:
return <SignIn appState={appState} application={application} />;
}
};
export const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> =
observer(({ appState, application }) => {
const { currentPane } = appState.purchaseFlow;
return (
<div className="flex items-center justify-center h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-2">
<div className="relative fit-content">
<div className="relative p-12 mb-4 bg-default border-1 border-solid border-gray-300 rounded">
<SNLogoFull className="mb-5" />
<PurchaseFlowPaneSelector
currentPane={currentPane}
appState={appState}
application={application}
/>
</div>
<div className="flex justify-end">
<a
className="mr-3 font-medium color-grey-1"
href="https://standardnotes.com/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy
</a>
<a
className="font-medium color-grey-1"
href="https://standardnotes.com/help"
target="_blank"
rel="noopener noreferrer"
>
Help
</a>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,33 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { isDesktopApplication } from '@/utils';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { PurchaseFlowView } from './PurchaseFlowView';
export type PurchaseFlowWrapperProps = {
appState: AppState;
application: WebApplication;
};
export const loadPurchaseFlowUrl = async (
application: WebApplication
): Promise<void> => {
const url = await application.getPurchaseFlowUrl();
if (url) {
const currentUrl = window.location.href.split('/?')[0];
const successUrl = isDesktopApplication()
? `standardnotes://${currentUrl}`
: currentUrl;
window.location.assign(`${url}&success_url=${successUrl}`);
}
};
export const PurchaseFlowWrapper: FunctionComponent<PurchaseFlowWrapperProps> =
observer(({ appState, application }) => {
if (!appState.purchaseFlow.isOpen) {
return null;
}
return <PurchaseFlowView appState={appState} application={application} />;
});

View File

@@ -0,0 +1,8 @@
import { toDirective } from '@/components/utils';
import {
PurchaseFlowWrapper,
PurchaseFlowWrapperProps,
} from './PurchaseFlowWrapper';
export const PurchaseFlowDirective =
toDirective<PurchaseFlowWrapperProps>(PurchaseFlowWrapper);

View File

@@ -0,0 +1,200 @@
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import Illustration from '../../../svg/create-account-illustration.svg';
import Circle from '../../../svg/circle-55.svg';
import BlueDot from '../../../svg/blue-dot.svg';
import Diamond from '../../../svg/diamond-with-horizontal-lines.svg';
import { FloatingLabelInput } from '@/components/FloatingLabelInput';
import { isEmailValid } from '@/utils';
import { loadPurchaseFlowUrl } from '../PurchaseFlowWrapper';
type Props = {
appState: AppState;
application: WebApplication;
};
export const CreateAccount: FunctionComponent<Props> = observer(
({ appState, application }) => {
const { setCurrentPane } = appState.purchaseFlow;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [isEmailInvalid, setIsEmailInvalid] = useState(false);
const [isPasswordNotMatching, setIsPasswordNotMatching] = useState(false);
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
const confirmPasswordInputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (emailInputRef.current) emailInputRef.current.focus();
}, []);
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
setIsEmailInvalid(false);
}
};
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
}
};
const handleConfirmPasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setConfirmPassword(e.target.value);
setIsPasswordNotMatching(false);
}
};
const handleSignInInstead = () => {
setCurrentPane(PurchaseFlowPane.SignIn);
};
const handleCreateAccount = async () => {
if (!email) {
emailInputRef?.current.focus();
return;
}
if (!isEmailValid(email)) {
setIsEmailInvalid(true);
emailInputRef?.current.focus();
return;
}
if (!password) {
passwordInputRef?.current.focus();
return;
}
if (!confirmPassword) {
confirmPasswordInputRef?.current.focus();
return;
}
if (password !== confirmPassword) {
setConfirmPassword('');
setIsPasswordNotMatching(true);
confirmPasswordInputRef?.current.focus();
return;
}
setIsCreatingAccount(true);
try {
const response = await application.register(email, password);
if (response.error || response.data?.error) {
throw new Error(
response.error?.message || response.data?.error?.message
);
} else {
loadPurchaseFlowUrl(application).catch((err) => {
console.error(err);
application.alertService.alert(err);
});
}
} catch (err) {
console.error(err);
application.alertService.alert(err as string);
} finally {
setIsCreatingAccount(false);
}
};
return (
<div className="flex items-center">
<Circle className="absolute w-8 h-8 top-40% -left-28" />
<BlueDot className="absolute w-4 h-4 top-35% -left-10" />
<Diamond className="absolute w-26 h-26 -bottom-5 left-0 -translate-x-1/2 -z-index-1" />
<Circle className="absolute w-8 h-8 bottom-35% -right-20" />
<BlueDot className="absolute w-4 h-4 bottom-25% -right-10" />
<Diamond className="absolute w-18 h-18 top-0 -right-2 translate-x-1/2 -z-index-1" />
<div className="mr-12">
<h1 className="mt-0 mb-2 text-2xl">Create your free account</h1>
<div className="mb-4 font-medium text-sm">
to continue to Standard Notes.
</div>
<form onSubmit={handleCreateAccount}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-90 ${isEmailInvalid ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isCreatingAccount}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid ? (
<div className="color-dark-red mb-4">
Please provide a valid email.
</div>
) : null}
<FloatingLabelInput
className="min-w-90 mb-4"
id="purchase-create-account-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isCreatingAccount}
/>
<FloatingLabelInput
className={`min-w-90 ${
isPasswordNotMatching ? 'mb-2' : 'mb-4'
}`}
id="create-account-confirm"
type="password"
label="Repeat password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
ref={confirmPasswordInputRef}
disabled={isCreatingAccount}
isInvalid={isPasswordNotMatching}
/>
{isPasswordNotMatching ? (
<div className="color-dark-red mb-4">
Passwords don't match. Please try again.
</div>
) : null}
</div>
</form>
<div className="flex justify-between">
<button
onClick={handleSignInInstead}
disabled={isCreatingAccount}
className="p-0 bg-default border-0 font-medium color-info cursor-pointer hover:underline"
>
Sign in instead
</button>
<Button
className="py-2.5"
type="primary"
label={
isCreatingAccount ? 'Creating account...' : 'Create account'
}
onClick={handleCreateAccount}
disabled={isCreatingAccount}
/>
</div>
</div>
<Illustration />
</div>
);
}
);

View File

@@ -0,0 +1,175 @@
import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PurchaseFlowPane } from '@/ui_models/app_state/purchase_flow_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import Circle from '../../../svg/circle-55.svg';
import BlueDot from '../../../svg/blue-dot.svg';
import Diamond from '../../../svg/diamond-with-horizontal-lines.svg';
import { FloatingLabelInput } from '@/components/FloatingLabelInput';
import { isEmailValid } from '@/utils';
import { loadPurchaseFlowUrl } from '../PurchaseFlowWrapper';
type Props = {
appState: AppState;
application: WebApplication;
};
export const SignIn: FunctionComponent<Props> = observer(
({ appState, application }) => {
const { setCurrentPane } = appState.purchaseFlow;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const [isEmailInvalid, setIsEmailInvalid] = useState(false);
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false);
const [otherErrorMessage, setOtherErrorMessage] = useState('');
const emailInputRef = useRef<HTMLInputElement>();
const passwordInputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (emailInputRef.current) emailInputRef.current.focus();
}, []);
const handleEmailChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setEmail(e.target.value);
setIsEmailInvalid(false);
}
};
const handlePasswordChange = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
setPassword(e.target.value);
setIsPasswordInvalid(false);
setOtherErrorMessage('');
}
};
const handleCreateAccountInstead = () => {
if (isSigningIn) return;
setCurrentPane(PurchaseFlowPane.CreateAccount);
};
const handleSignIn = async () => {
if (!email) {
emailInputRef?.current.focus();
return;
}
if (!isEmailValid(email)) {
setIsEmailInvalid(true);
emailInputRef?.current.focus();
return;
}
if (!password) {
passwordInputRef?.current.focus();
return;
}
setIsSigningIn(true);
try {
const response = await application.signIn(email, password);
if (response.error || response.data?.error) {
throw new Error(
response.error?.message || response.data?.error?.message
);
} else {
loadPurchaseFlowUrl(application).catch((err) => {
console.error(err);
application.alertService.alert(err);
});
}
} catch (err) {
console.error(err);
if ((err as Error).toString().includes('Invalid email or password')) {
setIsSigningIn(false);
setIsEmailInvalid(true);
setIsPasswordInvalid(true);
setOtherErrorMessage('Invalid email or password.');
setPassword('');
} else {
application.alertService.alert(err as string);
}
}
};
return (
<div className="flex items-center">
<Circle className="absolute w-8 h-8 top-35% -left-56" />
<BlueDot className="absolute w-4 h-4 top-30% -left-40" />
<Diamond className="absolute w-26 h-26 -bottom-5 left-0 -translate-x-1/2 -z-index-1" />
<Circle className="absolute w-8 h-8 bottom-30% -right-56" />
<BlueDot className="absolute w-4 h-4 bottom-20% -right-44" />
<Diamond className="absolute w-18 h-18 top-0 -right-2 translate-x-1/2 -z-index-1" />
<div>
<h1 className="mt-0 mb-2 text-2xl">Sign in</h1>
<div className="mb-4 font-medium text-sm">
to continue to Standard Notes.
</div>
<form onSubmit={handleSignIn}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-90 ${
isEmailInvalid && !otherErrorMessage ? 'mb-2' : 'mb-4'
}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isSigningIn}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid && !otherErrorMessage ? (
<div className="color-dark-red mb-4">
Please provide a valid email.
</div>
) : null}
<FloatingLabelInput
className={`min-w-90 ${otherErrorMessage ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isSigningIn}
isInvalid={isPasswordInvalid}
/>
{otherErrorMessage ? (
<div className="color-dark-red mb-4">{otherErrorMessage}</div>
) : null}
</div>
<Button
className={`${isSigningIn ? 'min-w-30' : 'min-w-24'} py-2.5 mb-5`}
type="primary"
label={isSigningIn ? 'Signing in...' : 'Sign in'}
onClick={handleSignIn}
disabled={isSigningIn}
/>
</form>
<div className="text-sm font-medium color-grey-1">
Dont have an account yet?{' '}
<a
className={`color-info ${
isSigningIn ? 'cursor-not-allowed' : 'cursor-pointer '
}`}
onClick={handleCreateAccountInstead}
>
Create account
</a>
</div>
</div>
</div>
);
}
);