diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index e94e181c9..09ae253cb 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -64,6 +64,7 @@ import { IconDirective } from './components/Icon'; import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; import { PreferencesDirective } from './preferences'; import { AppVersion, IsWebPlatform } from '@/version'; +import { PurchaseFlowDirective } from './purchaseFlow'; import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu'; function reloadHiddenFirefoxTab(): boolean { @@ -165,7 +166,8 @@ const startApplication: StartApplication = async function startApplication( .directive('notesOptionsPanel', NotesOptionsPanelDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('preferences', PreferencesDirective); + .directive('preferences', PreferencesDirective) + .directive('purchaseFlow', PurchaseFlowDirective); // Filters angular.module('app').filter('trusted', ['$sce', trusted]); diff --git a/app/assets/javascripts/components/FloatingLabelInput.tsx b/app/assets/javascripts/components/FloatingLabelInput.tsx new file mode 100644 index 000000000..79d8820b4 --- /dev/null +++ b/app/assets/javascripts/components/FloatingLabelInput.tsx @@ -0,0 +1,75 @@ +import { FunctionComponent, Ref } from 'preact'; +import { JSXInternal } from 'preact/src/jsx'; +import { forwardRef } from 'preact/compat'; +import { useState } from 'preact/hooks'; + +type Props = { + id: string; + type: 'text' | 'email' | 'password'; // Have no use cases for other types so far + label: string; + value: string; + onChange: JSXInternal.GenericEventHandler; + disabled?: boolean; + className?: string; + labelClassName?: string; + inputClassName?: string; + isInvalid?: boolean; +}; + +export const FloatingLabelInput: FunctionComponent = forwardRef( + ( + { + id, + type, + label, + disabled, + value, + isInvalid, + onChange, + className = '', + labelClassName = '', + inputClassName = '', + }, + ref: Ref + ) => { + const [focused, setFocused] = useState(false); + + const BASE_CLASSNAME = `relative bg-default`; + + const LABEL_CLASSNAME = `hidden absolute ${ + !focused ? 'color-neutral' : 'color-info' + } ${focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''} ${ + isInvalid ? 'color-dark-red' : '' + } ${labelClassName}`; + + const INPUT_CLASSNAME = `w-full h-full ${ + focused || value ? 'pt-6 pb-2' : 'py-2.5' + } px-3 text-input border-1 border-solid border-gray-300 rounded placeholder-medium text-input focus:ring-info ${ + isInvalid ? 'border-dark-red placeholder-dark-red' : '' + } ${inputClassName}`; + + const handleFocus = () => setFocused(true); + + const handleBlur = () => setFocused(false); + + return ( +
+ + +
+ ); + } +); diff --git a/app/assets/javascripts/purchaseFlow/PurchaseFlowView.tsx b/app/assets/javascripts/purchaseFlow/PurchaseFlowView.tsx new file mode 100644 index 000000000..a8539c51e --- /dev/null +++ b/app/assets/javascripts/purchaseFlow/PurchaseFlowView.tsx @@ -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 = ({ + currentPane, + appState, + application, +}) => { + switch (currentPane) { + case PurchaseFlowPane.CreateAccount: + return ; + case PurchaseFlowPane.SignIn: + return ; + } +}; + +export const PurchaseFlowView: FunctionComponent = + observer(({ appState, application }) => { + const { currentPane } = appState.purchaseFlow; + + return ( +
+
+
+ + +
+ +
+
+ ); + }); diff --git a/app/assets/javascripts/purchaseFlow/PurchaseFlowWrapper.tsx b/app/assets/javascripts/purchaseFlow/PurchaseFlowWrapper.tsx new file mode 100644 index 000000000..e1998f9d5 --- /dev/null +++ b/app/assets/javascripts/purchaseFlow/PurchaseFlowWrapper.tsx @@ -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 => { + 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 = + observer(({ appState, application }) => { + if (!appState.purchaseFlow.isOpen) { + return null; + } + + return ; + }); diff --git a/app/assets/javascripts/purchaseFlow/index.ts b/app/assets/javascripts/purchaseFlow/index.ts new file mode 100644 index 000000000..b82461d65 --- /dev/null +++ b/app/assets/javascripts/purchaseFlow/index.ts @@ -0,0 +1,8 @@ +import { toDirective } from '@/components/utils'; +import { + PurchaseFlowWrapper, + PurchaseFlowWrapperProps, +} from './PurchaseFlowWrapper'; + +export const PurchaseFlowDirective = + toDirective(PurchaseFlowWrapper); diff --git a/app/assets/javascripts/purchaseFlow/panes/CreateAccount.tsx b/app/assets/javascripts/purchaseFlow/panes/CreateAccount.tsx new file mode 100644 index 000000000..b7922cbc4 --- /dev/null +++ b/app/assets/javascripts/purchaseFlow/panes/CreateAccount.tsx @@ -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 = 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(); + const passwordInputRef = useRef(); + const confirmPasswordInputRef = useRef(); + + 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 ( +
+ + + + + + + + +
+

Create your free account

+
+ to continue to Standard Notes. +
+
+
+ + {isEmailInvalid ? ( +
+ Please provide a valid email. +
+ ) : null} + + + {isPasswordNotMatching ? ( +
+ Passwords don't match. Please try again. +
+ ) : null} +
+
+
+ +
+
+ +
+ ); + } +); diff --git a/app/assets/javascripts/purchaseFlow/panes/SignIn.tsx b/app/assets/javascripts/purchaseFlow/panes/SignIn.tsx new file mode 100644 index 000000000..565eb236c --- /dev/null +++ b/app/assets/javascripts/purchaseFlow/panes/SignIn.tsx @@ -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 = 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(); + const passwordInputRef = useRef(); + + 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 ( +
+ + + + + + + + +
+

Sign in

+
+ to continue to Standard Notes. +
+
+
+ + {isEmailInvalid && !otherErrorMessage ? ( +
+ Please provide a valid email. +
+ ) : null} + + {otherErrorMessage ? ( +
{otherErrorMessage}
+ ) : null} +
+
+
+ ); + } +); diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index ad3b87ae3..ee5c2ac87 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -23,6 +23,7 @@ import { NotesState } from './notes_state'; import { TagsState } from './tags_state'; import { AccountMenuState } from '@/ui_models/app_state/account_menu_state'; import { PreferencesState } from './preferences_state'; +import { PurchaseFlowState } from './purchase_flow_state'; import { QuickSettingsState } from './quick_settings_state'; export enum AppStateEvent { @@ -67,6 +68,7 @@ export class AppState { readonly accountMenu: AccountMenuState; readonly actionsMenu = new ActionsMenuState(); readonly preferences = new PreferencesState(); + readonly purchaseFlow: PurchaseFlowState; readonly noAccountWarning: NoAccountWarningState; readonly noteTags: NoteTagsState; readonly sync = new SyncState(); @@ -113,6 +115,7 @@ export class AppState { application, this.appEventObserverRemovers ); + this.purchaseFlow = new PurchaseFlowState(application); this.addAppEventObserver(); this.streamNotesAndTags(); this.onVisibilityChange = () => { @@ -284,6 +287,8 @@ export class AppState { break; case ApplicationEvent.Launched: this.locked = false; + if (window.location.search.includes('purchase=true')) + this.purchaseFlow.openPurchaseFlow(); break; case ApplicationEvent.SyncStatusChanged: this.sync.update(this.application.getSyncStatus()); diff --git a/app/assets/javascripts/ui_models/app_state/purchase_flow_state.ts b/app/assets/javascripts/ui_models/app_state/purchase_flow_state.ts new file mode 100644 index 000000000..3beed9b5a --- /dev/null +++ b/app/assets/javascripts/ui_models/app_state/purchase_flow_state.ts @@ -0,0 +1,38 @@ +import { action, makeObservable, observable } from 'mobx'; +import { WebApplication } from '../application'; + +export enum PurchaseFlowPane { + SignIn, + CreateAccount, +} + +export class PurchaseFlowState { + isOpen = false; + currentPane = PurchaseFlowPane.CreateAccount; + + constructor(private application: WebApplication) { + makeObservable(this, { + isOpen: observable, + currentPane: observable, + + setCurrentPane: action, + openPurchaseFlow: action, + closePurchaseFlow: action, + }); + } + + setCurrentPane = (currentPane: PurchaseFlowPane): void => { + this.currentPane = currentPane; + }; + + openPurchaseFlow = (): void => { + const user = this.application.getUser(); + if (!user) { + this.isOpen = true; + } + }; + + closePurchaseFlow = (): void => { + this.isOpen = false; + }; +} diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index 9bd688c89..785ce2df5 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -41,4 +41,7 @@ application='self.application' app-state='self.appState' ) - + purchase-flow( + application='self.application' + app-state='self.appState' + ) diff --git a/app/assets/stylesheets/_main.scss b/app/assets/stylesheets/_main.scss index b54211c3a..a6b059eb2 100644 --- a/app/assets/stylesheets/_main.scss +++ b/app/assets/stylesheets/_main.scss @@ -14,6 +14,8 @@ $z-index-footer-bar-item-panel: 2000; $z-index-preferences: 3000; +$z-index-purchase-flow: 4000; + $z-index-lock-screen: 10000; $z-index-modal: 10000; @@ -244,3 +246,7 @@ $footer-height: 2rem; .z-index-preferences { z-index: $z-index-preferences; } + +.z-index-purchase-flow { + z-index: $z-index-purchase-flow; +} diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index dd594c2f1..aa520fac6 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -1,9 +1,21 @@ /* Components and utilities that are good candidates for extraction to StyleKit. */ +:root { + --sn-stylekit-grey-2: #f8f9fc; +} + +.bg-grey-2 { + background-color: var(--sn-stylekit-grey-2); +} + .h-90vh { height: 90vh; } +.h-26 { + width: 6.5rem; +} + .h-33 { height: 8.25rem; } @@ -219,6 +231,10 @@ margin-right: 0.75rem; } +.mr-12 { + margin-right: 3rem; +} + .my-0\.5 { margin-top: 0.125rem; margin-bottom: 0.125rem; @@ -234,14 +250,30 @@ margin-bottom: 1rem; } +.mt-0 { + margin-top: 0; +} + .mb-2 { margin-bottom: 0.5rem; } +.mb-4 { + margin-bottom: 1rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + .max-w-89 { max-width: 22.25rem; } +.w-26 { + width: 6.5rem; +} + .w-92 { width: 23rem; } @@ -274,6 +306,18 @@ min-width: 3.75rem; } +.min-w-24 { + min-width: 6rem; +} + +.min-w-30 { + min-width: 7.5rem; +} + +.min-w-90 { + min-width: 22.5rem; +} + .min-h-1px { min-height: 1px; } @@ -314,6 +358,18 @@ color: var(--sn-stylekit-background-color); } +.p-1 { + padding: 0.25rem; +} + +.p-8 { + padding: 2rem; +} + +.p-12 { + padding: 3rem; +} + .pt-1 { padding-top: 0.25rem; } @@ -326,10 +382,26 @@ padding-top: 0.5rem; } +.pt-3 { + padding-top: 0.75rem; +} + +.pt-6 { + padding-top: 1.5rem; +} + .pb-1 { padding-bottom: 0.25rem; } +.pb-2 { + padding-bottom: 0.5rem; +} + +.pb-2\.5 { + padding-bottom: 0.625rem; +} + .px-9 { padding-left: 2.25rem; padding-right: 2.25rem; @@ -340,6 +412,11 @@ padding-right: 3rem; } +.sn-component .py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + .py-9 { padding-top: 2.25rem; padding-bottom: 2.25rem; @@ -349,6 +426,118 @@ user-select: none; } +.placeholder-dark-red::placeholder { + @extend .color-dark-red; +} + +.placeholder-medium::placeholder { + font-weight: 500; +} + +.top-30\% { + top: 30%; +} + +.top-35\% { + top: 35%; +} + +.top-40\% { + top: 40%; +} + +.-top-0\.25 { + top: -0.0625rem; +} + +.bottom-20\% { + bottom: 20%; +} + +.bottom-25\% { + bottom: 25%; +} + +.bottom-30\% { + bottom: 30%; +} + +.bottom-35\% { + bottom: 35%; +} + +.bottom-40\% { + bottom: 40%; +} + +.left-2 { + left: 0.5rem; +} + +.-left-10 { + left: -2.5rem; +} + +.-left-28 { + left: -7rem; +} + +.-left-16 { + left: -4rem; +} + +.-left-40 { + left: -10rem; +} + +.-left-56 { + left: -14rem; +} + +.-right-2 { + right: -0.5rem; +} + +.-right-10 { + right: -2.5rem; +} + +.-right-20 { + right: -5rem; +} + +.-right-24 { + right: -6rem; +} + +.-right-44 { + right: -11rem; +} + +.-right-56 { + right: -14rem; +} + +.-translate-x-1\/2 { + transform: translateX(-50%); +} + +.-translate-y-1\/2 { + transform: translateY(-50%); +} + +.translate-x-1\/2 { + transform: translateX(50%); +} + +.-bottom-5 { + bottom: -1.25rem; +} + +.-z-index-1 { + z-index: -1; +} + .sn-component .btn-w-full { width: 100%; } diff --git a/app/assets/svg/blue-dot.svg b/app/assets/svg/blue-dot.svg new file mode 100644 index 000000000..dacc6754e --- /dev/null +++ b/app/assets/svg/blue-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/svg/circle-55.svg b/app/assets/svg/circle-55.svg new file mode 100644 index 000000000..e0ca48fbe --- /dev/null +++ b/app/assets/svg/circle-55.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/app/assets/svg/create-account-illustration.svg b/app/assets/svg/create-account-illustration.svg new file mode 100644 index 000000000..1e06d2b5e --- /dev/null +++ b/app/assets/svg/create-account-illustration.svg @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/svg/diamond-with-horizontal-lines.svg b/app/assets/svg/diamond-with-horizontal-lines.svg new file mode 100644 index 000000000..b05e2d28c --- /dev/null +++ b/app/assets/svg/diamond-with-horizontal-lines.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/app/assets/svg/ic-sn-logo-full.svg b/app/assets/svg/ic-sn-logo-full.svg new file mode 100644 index 000000000..f1e356972 --- /dev/null +++ b/app/assets/svg/ic-sn-logo-full.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file