From 957bc1c0dae25f5fbf2c04e801bd1c982d491253 Mon Sep 17 00:00:00 2001 From: Malted Date: Thu, 6 Apr 2023 20:21:06 +0100 Subject: [PATCH] Redesign Bank signup flow (#728) --- components/bank/apply-button.js | 29 ++ components/bank/apply/address-input.js | 160 +++++++ components/bank/apply/alert-modal.js | 37 ++ components/bank/apply/autofill-colour-fix.js | 7 + components/bank/apply/bank-info.js | 96 ++++ components/bank/apply/checkbox.js | 39 ++ components/bank/apply/field.js | 52 ++ components/bank/apply/form-container.js | 25 + components/bank/apply/nav-button.js | 158 ++++++ components/bank/apply/org-form.js | 75 +++ components/bank/apply/personal-form.js | 90 ++++ components/bank/apply/progress.js | 85 ++++ components/bank/apply/watermark.js | 82 ++++ components/bank/form.js | 428 ----------------- components/bank/signup.js | 103 ---- components/bank/start.js | 72 ++- components/bank/stats.js | 154 +++--- components/bank/timeline.js | 182 ++----- components/flex-col.js | 9 + components/footer.js | 4 +- components/icon.js | 1 - components/index/cards/sprig.js | 9 +- lib/bank/apply/address-validation.js | 81 ++++ package.json | 1 + pages/api/bank/apply.js | 19 +- pages/api/games.js | 2 +- pages/bank/apply.js | 130 ++++- pages/bank/fiscal-sponsorship.js | 477 +++++++++++++++++++ pages/bank/index.js | 3 + pages/bank/success.js | 164 ++++--- public/bank/apply/Squircle.svg | 3 + public/bank/apply/party-orpheus.svg | 31 ++ public/bank/timeline-steps/step1.svg | 4 + public/bank/timeline-steps/step2.svg | 8 + public/bank/timeline-steps/step3.svg | 4 + public/bank/timeline-steps/step4.svg | 4 + 36 files changed, 1958 insertions(+), 870 deletions(-) create mode 100644 components/bank/apply-button.js create mode 100644 components/bank/apply/address-input.js create mode 100644 components/bank/apply/alert-modal.js create mode 100644 components/bank/apply/autofill-colour-fix.js create mode 100644 components/bank/apply/bank-info.js create mode 100644 components/bank/apply/checkbox.js create mode 100644 components/bank/apply/field.js create mode 100644 components/bank/apply/form-container.js create mode 100644 components/bank/apply/nav-button.js create mode 100644 components/bank/apply/org-form.js create mode 100644 components/bank/apply/personal-form.js create mode 100644 components/bank/apply/progress.js create mode 100644 components/bank/apply/watermark.js delete mode 100644 components/bank/form.js delete mode 100644 components/bank/signup.js create mode 100644 components/flex-col.js create mode 100644 lib/bank/apply/address-validation.js create mode 100644 pages/bank/fiscal-sponsorship.js create mode 100644 public/bank/apply/Squircle.svg create mode 100644 public/bank/apply/party-orpheus.svg create mode 100644 public/bank/timeline-steps/step1.svg create mode 100644 public/bank/timeline-steps/step2.svg create mode 100644 public/bank/timeline-steps/step3.svg create mode 100644 public/bank/timeline-steps/step4.svg diff --git a/components/bank/apply-button.js b/components/bank/apply-button.js new file mode 100644 index 00000000..de8fde2d --- /dev/null +++ b/components/bank/apply-button.js @@ -0,0 +1,29 @@ +import { Button, Text, Image, Flex } from 'theme-ui' +import Icon from '../icon' + +export default function ApplyButton() { + return ( + + ) +} \ No newline at end of file diff --git a/components/bank/apply/address-input.js b/components/bank/apply/address-input.js new file mode 100644 index 00000000..e46c54ac --- /dev/null +++ b/components/bank/apply/address-input.js @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from 'react' +import { Box, Flex, Input, Text } from 'theme-ui' +import FlexCol from '../../flex-col' +import AutofillColourFix from './autofill-colour-fix' +import { geocode } from '../../../lib/bank/apply/address-validation' +import Icon from '../../icon' + +const approvedCountries = ['US', 'CA', 'MX']; + +export default function AutoComplete({ name, isPersonalAddressInput }) { + const input = useRef() + const base = useRef() + const [predictions, setPredictions] = useState(null) + const [countryCode, setCountryCode] = useState(null) + + const performGeocode = async (address) => { + if (isPersonalAddressInput) return + geocode(address) + .then((res) => { + const country = res?.results[0]?.country + const countryCode = res?.results[0]?.countryCode + + setCountryCode(countryCode) + + sessionStorage.setItem('bank-signup-eventCountry', country) + sessionStorage.setItem('bank-signup-eventCountryCode', countryCode) + }) + .catch((err) => console.error(err)) + } + + const optionClicked = async (prediction) => { + input.current.value = prediction.description + performGeocode(prediction.description) + setPredictions(null) + } + const clickOutside = (e) => { + if (input.current && !input.current.contains(e.target)) { + setPredictions(null) + } + } + + //TODO: Close suggestions view when focus is lost via tabbing. + //TODO: Navigate suggestions with arrow keys. + + useEffect(() => { + if (!window.google || !input.current) return + + const service = new window.google.maps.places.AutocompleteService() + + const onInput = async (e) => { + if (!e.target.value) { + setPredictions(null) + } else { + service.getPlacePredictions( + { input: e.target.value }, + (predictions, status) => { + setPredictions(predictions) + if (status !== window.google.maps.places.PlacesServiceStatus.OK) { //DEBUG + setPredictions([]) + } + } + ) + } + } + + document.addEventListener('click', clickOutside) + input.current.addEventListener('input', onInput) + input.current.addEventListener('focus', onInput) + + return () => { + document.removeEventListener('click', clickOutside) + input.current?.removeEventListener('input', onInput) + input.current?.removeEventListener('focus', onInput) + } + }, []) + + return ( + + + performGeocode(e.target.value)} + /> + + {/* {String(countryCode)} */} + {countryCode && !approvedCountries.includes(countryCode) && + + + + Currently, we only have first-class support for organizations in the United States, Canada, and Mexico.
+ If you're somewhere else, you can still use bank!
+ Please contact us at bank@hackclub.com +
+
+ } +
+
+ { predictions && + + + { predictions.map((prediction, idx) => ( + <> + optionClicked(prediction)} + sx={{ + cursor: 'pointer', + border: 'none', + background: 'none', + color: '#d1cbe7', + '&:hover': { + color: 'white', + }, + fontFamily: 'inherit', + fontSize: 'inherit', + textAlign: 'inherit', + }} + key={prediction.id} + > + {prediction.description} + + + { + idx < predictions.length - 1 && +
+ } + + ))} +
+
+ } +
+ ) +} \ No newline at end of file diff --git a/components/bank/apply/alert-modal.js b/components/bank/apply/alert-modal.js new file mode 100644 index 00000000..d4ad225f --- /dev/null +++ b/components/bank/apply/alert-modal.js @@ -0,0 +1,37 @@ +import { Box, Button, Flex, Text } from 'theme-ui' +import Icon from '../../icon' + +export default function AlertModal({ formError, setFormError }) { + if (!formError) return null + + const close = () => setFormError(null) + + return ( + + + + Oops! + {formError} + + + + ) +} \ No newline at end of file diff --git a/components/bank/apply/autofill-colour-fix.js b/components/bank/apply/autofill-colour-fix.js new file mode 100644 index 00000000..3f413ff2 --- /dev/null +++ b/components/bank/apply/autofill-colour-fix.js @@ -0,0 +1,7 @@ +export default { + '&:-webkit-autofill': { + boxShadow: '0 0 0 100px #252429 inset !important', + WebkitTextFillColor: 'white', + }, +} +//TODO: Move to main theme \ No newline at end of file diff --git a/components/bank/apply/bank-info.js b/components/bank/apply/bank-info.js new file mode 100644 index 00000000..d07d108d --- /dev/null +++ b/components/bank/apply/bank-info.js @@ -0,0 +1,96 @@ +import { Box, Flex, Link, Text } from 'theme-ui' +import Icon from '../../icon' +import FlexCol from '../../flex-col' + +export default function BankInfo() { + return ( + + + + + What Hack Club Bank is + + + + + + A fiscal sponsor + + + + +
    +
  • + Nonprofit status. +
  • +
  • + Tax-deductable donations. +
  • +
+
+
+ + A financial platform + +
    +
  • + A donations page and invoicing system. +
  • +
  • + Transfer money electronically. +
  • +
  • + Order cards for you and your team to + make purchases. +
  • +
+
+
+
+
+ + + What Hack Club Bank is not + + + + A bank! (we're better) + +
    +
  • + Rather than setting up a standard bank account, + you'll get a restricted fund within Hack Club accounts. +
  • +
  • + You can't deposit or withdraw money. +
  • +
+
+
+ + For-profit + +
    +
  • + If you’re a for-profit entity, then Bank is not for you. + Consider setting up a business. +
  • +
+
+
+
+
+
+
+ ); +} diff --git a/components/bank/apply/checkbox.js b/components/bank/apply/checkbox.js new file mode 100644 index 00000000..139e1544 --- /dev/null +++ b/components/bank/apply/checkbox.js @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import Icon from '../../icon' + +export default function Checkbox({ name, defaultChecked=false, size=38 }) { + const [checked, setChecked] = useState(defaultChecked) + const toggle = () => setChecked(!checked) + + /* Fill in the field with the value from sessionStorage. + For other input elements, the value is set in the Field component, + but these checkboxes hold their state in useState rather than in the DOM. */ + useEffect(() => { + const value = sessionStorage.getItem('bank-signup-' + name) + if (value) { + const input = document.getElementById(name) + input && setChecked(value === 'true') + } + }, []) + + return (<> + + toggle()} + onKeyDown={(e) => e.key === 'Enter' && toggle()} + /> + + ) +} \ No newline at end of file diff --git a/components/bank/apply/field.js b/components/bank/apply/field.js new file mode 100644 index 00000000..9c9ecddc --- /dev/null +++ b/components/bank/apply/field.js @@ -0,0 +1,52 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { Box, Flex, Label, Text } from 'theme-ui' +import FlexCol from '../../flex-col' + +export default function Field({ name, label, description, col = true, requiredFields, children }) { + const router = useRouter() + const isRequired = requiredFields[parseInt(router.query.step) - 1].includes(name) + + /* Fill in the field input element with the value from sessionStorage. + Note: the custom checkbox component does this in its own useEffect hook. */ + useEffect(() => { + const value = sessionStorage.getItem('bank-signup-' + name) + if (value) { + const input = document.getElementById(name) + if (input) input.value = value + } + }, []) + + return ( + + + + + { isRequired && + Required + } + + { children } + + { description && + { description } + } + + ) +} diff --git a/components/bank/apply/form-container.js b/components/bank/apply/form-container.js new file mode 100644 index 00000000..08d71def --- /dev/null +++ b/components/bank/apply/form-container.js @@ -0,0 +1,25 @@ +import { forwardRef } from 'react' +import { Box } from 'theme-ui' + +export default forwardRef(({ children }, ref) => { + return ( + + { children } + + ) +}) diff --git a/components/bank/apply/nav-button.js b/components/bank/apply/nav-button.js new file mode 100644 index 00000000..d529bfd5 --- /dev/null +++ b/components/bank/apply/nav-button.js @@ -0,0 +1,158 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { Button, Flex, Text, Spinner } from 'theme-ui' + +async function sendApplication() { + // Get the form data from sessionStorage + const data = {} + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i) + if (key.startsWith('bank-signup-')) { + data[key.replace('bank-signup-', '')] = sessionStorage.getItem(key) + } + } + console.dir('Sending data:', data) + + // Send the data + try { + const res = await fetch('/api/bank/apply', { + method: 'POST', + cors: 'no-cors', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } catch (error) { + console.error(error); + } +} + +function NavIcon({ isBack }) { + const style = { + height: '1em', + fill: 'white', + margin: 0, + flexShrink: 0, + } + + return isBack ? + + + + + + : + + + + + +} + +export default function NavButton({ isBack, form, clickHandler, requiredFields, setFormError }) { + const router = useRouter() + const [spinner, setSpinner] = useState(false) + + useEffect(() => { + setSpinner(false) + }, [router.query.step]) + + const minStep = 1 + const maxStep = 3 + + const click = async () => { + setSpinner(true) + + let step = parseInt(router.query.step) + + async function setStep(s) { + await router.push({ + pathname: router.pathname, + query: { ...router.query, step: s } }, + undefined, + {} + ) + } + + if (!step) { + // Set the step query param to minStep if it's not there. + await setStep(minStep) + } else if (step === minStep && isBack) { + await router.push('/bank') + return + } else if (step < minStep) { + // Set the step query param to minStep if it's lower than that. + await setStep(minStep) + } + + /* Don't return from inside the loop since + we want all input values to be saved every time */ + let wasError = false; + + // Save form data + new FormData(form.current).forEach((value, key) => { + sessionStorage.setItem('bank-signup-' + key, value) + + // Check if there are empty required fields. + if ( + !isBack && + (!value || value.trim() === "") + && requiredFields[step - 1].includes(key) + ) { + setFormError("Please fill all required fields") + wasError = true + setSpinner(false) + } + }) + if (wasError) return + + // Run the parent's click handler for this button. + if (clickHandler) await clickHandler() + + if (step >= maxStep && !isBack) { + await sendApplication() + await router.push('/bank/apply/success') + return + } else { + step += isBack ? -1 : 1 + } + await setStep(step) + } + + return ( + + ) +} \ No newline at end of file diff --git a/components/bank/apply/org-form.js b/components/bank/apply/org-form.js new file mode 100644 index 00000000..a174b8d8 --- /dev/null +++ b/components/bank/apply/org-form.js @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react' +import { Input, Textarea } from 'theme-ui' +import Checkbox from './checkbox' +import AddressInput from './address-input' +import Field from './field' +import AutofillColourFix from './autofill-colour-fix' + +export default function OrganizationInfoForm({ requiredFields }) { + const [org, setOrg] = useState('organization') + + useEffect(() => { + if (navigator.language === 'en-GB') setOrg('organisation') + }, []) + + return ( + <> + + + + + + + + + + + + + +