Redesign Bank signup flow (#728)

This commit is contained in:
Malted 2023-04-06 20:21:06 +01:00 committed by GitHub
parent fd25fda1eb
commit 957bc1c0da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1958 additions and 870 deletions

View file

@ -0,0 +1,29 @@
import { Button, Text, Image, Flex } from 'theme-ui'
import Icon from '../icon'
export default function ApplyButton() {
return (
<Button
variant='ctaLg'
as='a'
href='apply'
sx={{
width: '100%',
height: '4.2rem',
// borderRadius: '1.5rem',
}}
>
<Flex sx={{
alignItems: 'center',
gap: 3,
mr: '-32px' // Man...
}}>
<Text
color='white'
sx={{ fontWeight: 'bold', fontSize: 4 }}
>Apply now</Text>
<Icon glyph='view-forward' size={46} color='white' />
</Flex>
</Button>
)
}

View file

@ -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 (
<Box sx={{ position: 'relative', width: '100%' }}>
<FlexCol flexDirection='column' position='relative' width='100%' gap='2'>
<Input
ref={input}
name={name}
id={name}
placeholder='Shelburne, VT'
autoComplete="off"
sx={{ ...AutofillColourFix }}
onInput={async (e) => performGeocode(e.target.value)}
/>
<Box>
{/* {String(countryCode)} */}
{countryCode && !approvedCountries.includes(countryCode) &&
<Flex sx={{ alignItems: 'center' }}>
<Icon glyph='sad' size='2.5rem' sx={{color: 'red', mr: 1, flexShrink: 0 }} />
<Text
as='label'
htmlFor={name}
sx={{
color: 'red',
// fontWeight: 'medium',
}}
>
Currently, we only have first-class support for organizations in the United States, Canada, and Mexico.<br />
If you're somewhere else, you can still use bank!<br />
Please contact us at bank@hackclub.com
</Text>
</Flex>
}
</Box>
</FlexCol>
{ predictions &&
<Box sx={{
background: '#47454f',
border: '1px solid #696675',
width: '100%',
p: 3,
borderRadius: '4px',
position: 'absolute',
bottom: 'calc(100% + 0.5em)',
}}>
<FlexCol gap={1}>
{ predictions.map((prediction, idx) => (
<>
<Text
as='button'
onClick={() => 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}
</Text>
{
idx < predictions.length - 1 &&
<hr
style={{
width: '100%',
color: '#8492a6',
}}
/>
}
</>
))}
</FlexCol>
</Box>
}
</Box>
)
}

View file

@ -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 (
<Box>
<Box onClick={close} sx={{
position: 'fixed',
inset: 0,
background: '#000000',
opacity: 0.5,
zIndex: 1000,
}} />
<Flex sx={{
flexDirection: 'column',
alignItems: 'center',
gap: 3,
background: '#252429',
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 1001,
padding: 4,
borderRadius: 'default'
}}>
<Text variant='title'>Oops!</Text>
<Text variant='lead'>{formError}</Text>
<Button onClick={close}>Dismiss</Button>
</Flex>
</Box>
)
}

View file

@ -0,0 +1,7 @@
export default {
'&:-webkit-autofill': {
boxShadow: '0 0 0 100px #252429 inset !important',
WebkitTextFillColor: 'white',
},
}
//TODO: Move to main theme

View file

@ -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 (
<Box>
<FlexCol gap={4}>
<FlexCol gap={4}>
<Text sx={{ fontSize: 36 }}>
What Hack Club Bank <i>is</i>
</Text>
<FlexCol gap={3} ml={3}>
<FlexCol gap={2}>
<Flex sx={{ alignItems: "center", gap: 2 }}>
<Link
color='white'
href="/bank/fiscal-sponsorship"
target='_blank'
sx={{
fontSize: 3,
display: "inline-flex",
alignItems: "flex-end",
gap: 1
}}
>
A fiscal sponsor
<Icon glyph="external" />
</Link>
</Flex>
<Text sx={{ color: "muted" }}>
<ul>
<li>
Nonprofit status.
</li>
<li>
Tax-deductable donations.
</li>
</ul>
</Text>
</FlexCol>
<FlexCol gap={2}>
<Text sx={{ fontSize: 3 }}>A financial platform</Text>
<Text sx={{ color: "muted" }}>
<ul>
<li>
A donations page and invoicing system.
</li>
<li>
Transfer money electronically.
</li>
<li>
Order cards for you and your team to
make purchases.
</li>
</ul>
</Text>
</FlexCol>
</FlexCol>
</FlexCol>
<FlexCol gap={4}>
<Text sx={{ fontSize: 36 }}>
What Hack Club Bank <i>is not</i>
</Text>
<FlexCol gap={3} ml={3}>
<FlexCol gap={2}>
<Text sx={{ fontSize: 3 }}>A bank! <Text sx={{ color: 'muted', fontSize: 2 }}>(we're better)</Text></Text>
<Text sx={{ color: "muted" }}>
<ul>
<li>
Rather than setting up a standard bank account,
you'll get a restricted fund within Hack Club accounts.
</li>
<li>
You can't deposit or withdraw money.
</li>
</ul>
</Text>
</FlexCol>
<FlexCol gap={2}>
<Text sx={{ fontSize: 3 }}>For-profit</Text>
<Text sx={{ color: "muted" }}>
<ul>
<li>
If youre a for-profit entity, then Bank is not for you.
Consider setting up a business.
</li>
</ul>
</Text>
</FlexCol>
</FlexCol>
</FlexCol>
</FlexCol>
</Box>
);
}

View file

@ -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 (<>
<input
aria-hidden='true'
type='hidden'
value={checked}
name={name}
/>
<Icon
glyph={checked ? 'checkmark' : 'checkbox'}
size={size}
id={name}
name={name}
aria-checked={checked}
role='checkbox'
tabindex='0'
onClick={() => toggle()}
onKeyDown={(e) => e.key === 'Enter' && toggle()}
/>
</>
)
}

View file

@ -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 (
<FlexCol gap={2} width={'100%'}>
<Flex sx={{
flexDirection: col ? 'column' : 'row',
alignItems: col ? 'flex-start' : 'center',
gap: 2,
}}>
<Flex sx={{ alignItems: 'center', gap: 2 }}>
<Label htmlFor={name} sx={{
textTransform: 'capitalize',
fontSize: 3,
width: 'fit-content',
}}>
{ label }
</Label>
{ isRequired && <Box sx={{
backgroundColor: 'muted',
padding: '4px 6px',
borderRadius: '999px',
lineHeight: '1',
fontSize: 14,
}}>
Required
</Box>}
</Flex>
{ children }
</Flex>
{ description && <Text sx={{ color: 'muted', fontSize: 1 }}>
{ description }
</Text> }
</FlexCol>
)
}

View file

@ -0,0 +1,25 @@
import { forwardRef } from 'react'
import { Box } from 'theme-ui'
export default forwardRef(({ children }, ref) => {
return (
<Box
ref={ref}
as='form'
sx={{
height: '100%',
width: ['100%', null, null, '50ch'],
flex: '1',
overflowY: ['none', null, null, 'auto'],
pr: [0, null, '2ch'],
pl: [0, null, 1],
pb: [0, null, 3],
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{ children }
</Box>
)
})

View file

@ -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 ?
<svg style={style} viewBox="10.73 7.72 9.27 16.53">
<g>
<path d="M19.768,23.89c0.354,-0.424 0.296,-1.055 -0.128,-1.408c-1.645,-1.377 -5.465,-4.762 -6.774,-6.482c1.331,-1.749 5.1,-5.085 6.774,-6.482c0.424,-0.353 0.482,-0.984 0.128,-1.408c-0.353,-0.425 -0.984,-0.482 -1.409,-0.128c-1.839,1.532 -5.799,4.993 -7.2,6.964c-0.219,0.312 -0.409,0.664 -0.409,1.054c0,0.39 0.19,0.742 0.409,1.053c1.373,1.932 5.399,5.462 7.2,6.964l0.001,0.001c0.424,0.354 1.055,0.296 1.408,-0.128Z" ></path>
</g>
</svg>
:
<svg style={style} viewBox="12.75 7.72 9.25 16.53">
<g>
<path d="M12.982,23.89c-0.354,-0.424 -0.296,-1.055 0.128,-1.408c1.645,-1.377 5.465,-4.762 6.774,-6.482c-1.331,-1.749 -5.1,-5.085 -6.774,-6.482c-0.424,-0.353 -0.482,-0.984 -0.128,-1.408c0.353,-0.425 0.984,-0.482 1.409,-0.128c1.839,1.532 5.799,4.993 7.2,6.964c0.219,0.312 0.409,0.664 0.409,1.054c0,0.39 -0.19,0.742 -0.409,1.053c-1.373,1.932 -5.399,5.462 -7.2,6.964l-0.001,0.001c-0.424,0.354 -1.055,0.296 -1.408,-0.128Z"></path>
</g>
</svg>
}
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 (
<Button
variant={ isBack ? 'outline' : 'ctaLg' }
sx={{
color: 'white',
width: '100%',
maxWidth: isBack ? '8rem' : '10rem',
position: 'relative',
}}
onClick={click}
>
<Flex sx={{
flexDirection: isBack ? 'row' : 'row-reverse',
justifyContent: 'center',
placeItems: 'center',
fontSize: isBack ? 2 : 4,
gap: [2, null, null, 3],
}}>
<NavIcon isBack={isBack} />
<Text
sx={{
textTransform: 'none',
fontWeight: 'bold',
}}
>
{isBack ? 'Back' : 'Next'}
</Text>
</Flex>
{!isBack && spinner && <Spinner sx={{
height: '32px',
color: 'white',
position: 'absolute',
right: '-0.3rem',
margin: '0 !important',
}} />}
</Button>
)
}

View file

@ -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 (
<>
<Field name='eventName' label={`${org} name`} requiredFields={requiredFields}>
<Input
name='eventName'
id='eventName'
placeholder='Shelburne School Hackathon'
sx={{...AutofillColourFix}}
/>
</Field>
<Field
name='eventWebsite'
label={`${org} website`}
description='If you dont have one yet, you can leave this blank.'
requiredFields={requiredFields}
>
<Input
name='eventWebsite'
id='eventWebsite'
type='url'
placeholder='hackclub.com'
sx={{...AutofillColourFix}}
/>
</Field>
<Field name='eventLocation' label={`${org} location`} requiredFields={requiredFields}>
<AddressInput isPersonalAddressInput={false} name='eventLocation' />
</Field>
<Field
name='transparent'
label='Transparency mode'
col={false}
description={`
Transparent accounts balances and donations are public.
You choose who has access to personal details.
This can be changed later.
As an example, Hack Club's finances are transparent!
`}
requiredFields={requiredFields}
>
<Checkbox defaultChecked={true} name='transparent' />
</Field>
<Field
name='eventDescription'
label={`Tell us about your ${org}!`}
description='1 or 2 sentences will suffice'
requiredFields={requiredFields}
>
<Textarea
name='eventDescription'
id='eventDescription'
rows={3}
sx={{
resize: 'vertical',
width: '100%',
...AutofillColourFix
}}
/>
</Field>
</>
)
}

View file

@ -0,0 +1,90 @@
import { Input, Flex } 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 PersonalInfoForm({ setValidationResult, requiredFields }) {
return (
<>
<Flex sx={{ justifyContent: 'space-between', gap: 4 }}>
<Field name='firstName' label='First name' requiredFields={requiredFields}>
<Input
name='firstName'
id='firstName'
placeholder='Fiona'
sx={{...AutofillColourFix}}
/>
</Field>
<Field name='lastName' label='Last name' requiredFields={requiredFields}>
<Input
name='lastName'
id='lastName'
placeholder='Hacksworth'
sx={{...AutofillColourFix}}
/>
</Field>
</Flex>
<Field name='userEmail' label='Email' requiredFields={requiredFields}>
<Input
name='userEmail'
id='userEmail'
type='email'
placeholder='fiona@hackclub.com'
sx={{...AutofillColourFix}}
/>
</Field>
<Field
name='userPhone'
label='Phone'
description='Well only use this if we need to get in touch with you urgently.'
requiredFields={requiredFields}
>
<Input
name='userPhone'
id='userPhone'
type='tel'
placeholder='(123) 456-7890'
sx={{...AutofillColourFix}}
/>
</Field>
<Field name='userBirthday' label='Birthday' requiredFields={requiredFields}>
<Input
name='userBirthday'
id='userBirthday'
type='date'
sx={{...AutofillColourFix}}
/>
</Field>
<Field name='referredBy' label='Who were you referred by?' requiredFields={requiredFields}>
<Input
name='referredBy'
id='referredBy'
placeholder='Max'
sx={{...AutofillColourFix}}
/>
</Field>
<Field
name='returningUser'
label='Have you used Bank before?'
col={false}
requiredFields={requiredFields}
>
<Checkbox name='returningUser' />
</Field>
<Field
name='userAddress'
label='Address'
description='This is so we can send you some swag and goodies if you ever request them!'
requiredFields={requiredFields}
>
<AddressInput
name='userAddress'
isPersonalAddressInput={true}
setValidationResult={setValidationResult}
/>
</Field>
</>
)
}

View file

@ -0,0 +1,85 @@
import { useRouter } from 'next/router'
import { Box, Flex, Text } from 'theme-ui'
import FlexCol from '../../flex-col'
function StepIcon({ completed, number }) {
let strokeColour = completed ? '#33d6a6' : '#8492a6'
let fillColour = completed ? '#33d6a6' : 'none'
return (
<Box sx={{ position: 'relative' }}>
<svg
style={{ translate: '0 1px' }}
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M20 38C36.5 38 38 36.5 38 20C38 3.5 36.5 2 20 2C3.5 2 2 3.5 2 20C2 36.5 3.5 38 20 38Z"
stroke={strokeColour}
fill={fillColour}
stroke-width="3"
/>
</svg>
<Flex
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
inset: '0',
}}
>
<Text
sx={{
color: 'white',
fontSize: 2,
}}
>
{ number }
</Text>
</Flex>
</Box>
)
}
function Step({ number, label, completed }) {
return (
<Flex sx={{ lineHeight: '1', alignItems: 'center', gap: '4' }}>
<StepIcon completed={completed} number={number + 1} />
<Text sx={{
fontSize: '3',
display: ['none', null, null, 'block']
}}>{label}</Text>
</Flex>
)
}
export default function Progress() {
const router = useRouter()
const step = parseInt(router.query.step)
const labels = [
'Intro',
'Organization info',
'Personal info'
]
return (
<Flex
sx={{
gap: 3,
translate: [0, null, null, '-1rem 0'],
flexDirection: ['row', null, null, 'column']
}}
>
{labels.map((label, i) => (
<Step number={i} label={label} completed={step > i} key={i} />
))}
</Flex>
)
}

View file

@ -0,0 +1,82 @@
import React, { useRef, useEffect } from 'react'
import { Box } from 'theme-ui'
export default function Watermark() {
/* This is going to come back to haunt me one day.
It's an abomination but it works ... for now */
const shineRef = useRef()
const svgRef = useRef()
// Mouse move event
const handleMouseMove = ({ clientX, clientY }) => {
if (!shineRef.current || !svgRef.current) return
const svgWidth = svgRef.current.clientWidth / 100
const svgFromTop = svgRef.current.getBoundingClientRect().top
const svgFromLeft = svgRef.current.getBoundingClientRect().left
shineRef.current.style.top = `${clientY / svgWidth + 6.2}px`
shineRef.current.style.left = `${clientX / svgWidth + 9.2}px`
}
// Bind event
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
return (
<Box
sx={{
position: 'absolute',
zIndex: -1,
top: -330,
left: -480,
}}
>
<svg
ref={svgRef}
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={{
position: 'fixed',
width: '4600px',
overflow: 'hidden',
}}
>
<defs>
<clipPath id="my-clip-path">
<path d="M16.194 8.096A2.397 2.397 0 0 0 16 8.018V6c.358 0 .735.149.997.264.297.13.676.326 1.077.555a37.817 37.817 0 0 1 2.878 1.864c2.15 1.518 2.548 1.817 4.755 3.61a.999.999 0 1 1-1.414 1.414C22 12 21.9 11.799 19.798 10.317a35.088 35.088 0 0 0-2.716-1.761 9.091 9.091 0 0 0-.888-.46zM15.806 8.096c.09-.04.153-.064.194-.078V6c-.358 0-.735.149-.997.264-.297.13-.676.326-1.077.555a37.817 37.817 0 0 0-2.878 1.864C8.9 10.201 8.5 10.5 6.293 12.293a.999.999 0 1 0 1.414 1.414c2.294-1.707 2.394-1.908 4.495-3.39a35.088 35.088 0 0 1 2.716-1.761c.365-.209.65-.357.888-.46zM7 24a1 1 0 0 1 1-1h16a1 1 0 0 1 0 2H8a1 1 0 0 1-1-1z"></path>
<path d="M16 22a1 1 0 0 1-1-1v-7a1 1 0 0 1 2 0v7a1 1 0 0 1-1 1zM21 22a1 1 0 0 1-1-1v-7a1 1 0 0 1 2 0v7a1 1 0 0 1-1 1zM11 22a1 1 0 0 1-1-1v-7a1 1 0 0 1 2 0v7a1 1 0 0 1-1 1z"></path>
</clipPath>
</defs>
<g id="clip-container">
<foreignObject id="clip-content" width="100%" height="100%">
<Box
sx={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: '#1d181f',
clipPath: 'url(#my-clip-path)',
}}
>
<Box
ref={shineRef}
sx={{
position: 'absolute',
width: '2px',
height: '2px',
borderRadius: '50%',
backgroundColor: 'red',
filter: 'blur(2px)',
}}
/>
</Box>
</foreignObject>
</g>
</svg>
</Box>
)
}

View file

@ -1,428 +0,0 @@
import {
Box,
Label,
Input,
Button,
Select,
Text,
Container,
Textarea,
Divider,
Link,
Flex
} from 'theme-ui'
import { useRouter } from 'next/router'
import Icon from '../icon'
import countries from '../../lib/countries'
import { useEffect, useState } from 'react'
export default function BankApplyForm() {
const { query } = useRouter()
const [values, setValues] = useState({
eventName: '',
firstName: '',
lastName: '',
userEmail: '',
eventWebsite: '',
eventLocation: '',
userPhone: '',
returningUser: '',
transparent: true
})
useEffect(() => {
setValues({
eventName: query.eventName || '',
firstName: query.firstName || '',
lastName: query.lastName || '',
userEmail: query.userEmail || '',
eventWebsite: query.eventWebsite || '',
eventLocation: query.eventLocation || '',
userPhone: query.userPhone || '',
returningUser: query.returningUser || '',
transparent: query.transparent || true
})
}, [query])
const handleChange = e => {
let isRadio = e.target.type === 'radio'
let newValue = e.target.value
if (isRadio) {
newValue = e.target.value.toString() === 'true'
}
setValues({ ...values, [e.target.name]: newValue })
}
return (
<Box>
<Flex sx={{ flexDirection: 'column', pl: 3 }}>
<Text
sx={{
fontSize: [36, null, 48],
fontWeight: 'bold',
color: 'primary'
}}
>
Apply for Hack Club Bank
</Text>
<Text sx={{ fontSize: 18, mb: 2 }}>
Hack Club Bank is open to all Hack Clubs, hackathons, and charitable
organizations in the US and Canada. There are three steps to getting
on Hack Club Bank:
<ol>
<li>Fill out this form</li>
<li>
Have a 30 minute introductory call with a member of the Hack Club
Bank team
</li>
<li>
Sign the Hack Club Bank fiscal sponsorship agreement (sent right
after the call)
</li>
</ol>
If you have any questions, give us a shout at{' '}
<Link as="a" href="mailto:bank@hackclub.com">
bank@hackclub.com
</Link>{' '}
or in the <strong>#bank</strong> channel on the{' '}
<Link href="/slack">Hack Club Slack</Link>!
</Text>
</Flex>
<Base method="POST" action="/api/bank/apply">
<Text variant="headline" sx={{ color: 'primary' }}>
Your Organization
</Text>
<Divider sx={{ borderColor: 'slate', mt: -2 }} />
<Field
label="Organization name"
name="eventName"
placeholder="Windy City Hacks"
helperText="What's the name of your organization?"
value={values.eventName}
onChange={handleChange}
required
/>
<Label
htmlFor="transparent"
sx={{ color: 'smoke', fontSize: 18, my: 2 }}
>
Would you like to make your account transparent?
<Select
name="transparent"
sx={{ bg: 'dark', mt: 1 }}
value={values.transparent}
onChange={handleChange}
>
<option value="Yes, please!">Yes, please!</option>
<option value="No, thanks.">No, thanks.</option>
</Select>
</Label>
<HelperText>
This can be changed at anytime. For transparent accounts, anyone can
see your balance and donations. You choose who has access to personal
details.{' '}
<Link as="a" href="https://bank.hackclub.com/hq" target="_blank">
Hack Club's finances
</Link>{' '}
are transparent, for example.
</HelperText>
<Field
label="Organization website"
name="eventWebsite"
placeholder="https://hackclub.com"
type="url"
helperText="If you don't have one yet, you can leave this blank."
value={values.eventWebsite}
onChange={handleChange}
/>
<Field
label="Organization location"
name="eventLocation"
placeholder="San Francisco, CA"
type="text"
helperText="If applicable, please format as: City, State."
value={values.eventLocation}
onChange={handleChange}
/>
<Label
htmlFor="eventCountry"
sx={{ color: 'smoke', fontSize: 18, pb: 2, my: 2 }}
>
Country
<Select
name="eventCountry"
defaultValue="Choose a country"
sx={{ bg: 'dark', my: 1 }}
>
<option value="" selected disabled>
Choose a country
</option>
<option value="United States">United States (US)</option>
<option value="Canada">Canada (CA)</option>
</Select>
<HelperText>
We're currently only able to support organizations operating out of
the United States or Canada. If you're outside of those countries,
and might be eligible to run on Bank, please shoot us an email on{' '}
<Link as="a" href="mailto:bank@hackclub.com">
bank@hackclub.com
</Link>
!{' '}
</HelperText>
</Label>
<Label
htmlFor="eventDescription"
sx={{ color: 'smoke', fontSize: 18, my: 2 }}
>
Tell us about your organization!
<Textarea
name="eventDescription"
sx={{ bg: 'dark', my: 1 }}
required
/>
<HelperText>
1-2 sentences summarizing what you'd like to use Hack Club Bank for.
This is just to help us know what to expect during the call!
</HelperText>
</Label>
<Text variant="headline" sx={{ color: 'primary' }}>
Your Profile
</Text>
<Divider sx={{ borderColor: 'slate', mt: -2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<Field
label="First name"
name="firstName"
placeholder="Fiona"
value={values.firstName}
onChange={handleChange}
required
/>
<Field
label="Last name"
name="lastName"
placeholder="Hackworth"
value={values.lastName}
onChange={handleChange}
required
/>
</Box>
<Field
label="Email address"
name="userEmail"
placeholder="fiona@hackclub.com"
type="email"
value={values.userEmail}
onChange={handleChange}
required
/>
<Field
label="Phone"
name="userPhone"
placeholder="1-855-625-HACK"
type="tel"
helperText="We'll only use this if we need to get in touch with you urgently."
value={values.userPhone}
onChange={handleChange}
required
/>
<Field
label="Birthday"
name="userBirthday"
type="date"
width="fit-content"
sx={{ height: '44px' }}
required
/>
<Field
label="How did you hear about Hack Club Bank?"
name="referredBy"
placeholder="Word of mouth, hackathon, etc."
value={values.referredBy}
onChange={handleChange}
required
/>
<Label
htmlFor="returningUser"
sx={{ color: 'smoke', fontSize: 18, my: 2 }}
>
Have you used Hack Club Bank before?
<Select
name="returningUser"
sx={{ bg: 'dark', mt: 1 }}
value={values.returningUser}
onChange={handleChange}
>
<option value="No, first time!">No, first time!</option>
<option value="Yes, I have used Hack Club Bank before">
Yes, I have used Hack Club Bank before
</option>
</Select>
</Label>
<Box sx={{ mb: 2 }}>
<Text
variant="subheadline"
sx={{ mt: 3, mb: 1, display: 'block', fontSize: 3 }}
>
Mailing Address
</Text>
<HelperText>
This is so we can send you some swag and goodies if you ever request
them!
</HelperText>
</Box>
<Field
label="Address (line 1)"
name="addressLine1"
placeholder="8605 Santa Monica Blvd"
type="text"
required
/>
<Field
label="Address (line 2)"
name="addressLine2"
placeholder="Suite 86294"
type="text"
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
columnGap: 2
}}
>
<Field
label="City"
name="addressCity"
placeholder="West Hollywood"
type="text"
required
/>
<Field
label="State"
name="addressState"
placeholder="CA"
type="text"
required
/>
<Field
label="Zip Code"
name="addressZip"
placeholder="90069"
type="text"
required
/>
<Field
label="Country"
name="addressCountry"
placeholder="USA"
type="text"
required
/>
</Box>
<Button
sx={{ bg: 'red', mt: [2, 3], py: 2, fontSize: 24 }}
type="submit"
>
Apply
</Button>
</Base>
</Box>
)
}
function Field({
placeholder,
label,
name,
type,
helperText,
width,
value,
onChange,
required,
sx
}) {
let isRadio = type === 'radio'
let isCheckbox = type === 'checkbox'
if (isCheckbox) {
return (
<>
<Label
htmlFor={name}
sx={{
color: 'smoke',
fontSize: 18,
my: 2,
...sx
}}
>
{label}
</Label>
<Input
name={name}
type={type}
value={value}
onChange={onChange}
required={required}
sx={{ mr: 2 }}
/>
</>
)
}
return (
<Box sx={{ my: 2 }}>
<Label htmlFor={name} sx={{ color: 'smoke', fontSize: 18 }}>
{label}
<Input
id={name}
placeholder={placeholder}
name={name}
type={type}
sx={{
bg: 'dark',
width: `${width ? width : '100%'}`,
my: helperText ? 1 : 0,
mt: 1,
...sx
}}
value={value}
onChange={onChange}
required={required}
/>
{helperText && <HelperText>{helperText}</HelperText>}
</Label>
</Box>
)
}
function HelperText({ children }) {
return (
<Text variant="caption" sx={{ color: 'muted', fontSize: 16 }}>
{children}
</Text>
)
}
function Base({ children, action, method }) {
return (
<Container
as="form"
sx={{ display: 'grid', gridTemplateColumns: '1fr' }}
action={action}
method={method}
variant="copy"
>
{children}
</Container>
)
}

View file

@ -1,103 +0,0 @@
import { Box, Input, Label, Button } from 'theme-ui'
import { useState } from 'react'
import { useRouter } from 'next/router'
function Base({ children, action, target, method, onSubmit }) {
return (
<Box
as="form"
sx={{ display: 'grid', gridTemplateColumns: '1fr' }}
action={action}
target={target}
method={method}
onSubmit={onSubmit}
>
{children}
</Box>
)
}
function Field({ placeholder, label, name, type, value, onChange }) {
return (
<Box sx={{ my: 2 }}>
<Label htmlFor={name} sx={{ color: 'muted', fontSize: 18 }}>
{label}
</Label>
<Input
id={name}
placeholder={placeholder}
name={name}
type={type}
sx={{
bg: 'dark'
}}
onChange={onChange}
value={value}
required
/>
</Box>
)
}
export default function Signup() {
const router = useRouter()
const [values, setValues] = useState({
eventName: '',
firstName: '',
lastName: '',
userEmail: ''
})
const handleSubmit = e => {
e.preventDefault()
const params = new URLSearchParams({
eventName: e.target.eventName.value,
firstName: e.target.firstName.value,
lastName: e.target.lastName.value,
userEmail: e.target.userEmail.value
})
router.push(`/bank/apply/?${params}`)
}
return (
<Base method="get" action="/bank/apply" onSubmit={handleSubmit}>
<Field
label="Organization name"
name="eventName"
placeholder="Windy City Hacks"
value={values.eventName}
onChange={e => setValues({ ...values, eventName: e.target.value })}
/>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
<Field
label="First name"
name="firstName"
placeholder="Fiona"
value={values.firstName}
onChange={e => setValues({ ...values, firstName: e.target.value })}
/>
<Field
label="Last name"
name="lastName"
placeholder="Hackworth"
value={values.lastName}
onChange={e => setValues({ ...values, lastName: e.target.value })}
/>
</Box>
<Field
label="Email address"
name="userEmail"
placeholder="fiona@hackclub.com"
type="email"
value={values.userEmail}
onChange={e => setValues({ ...values, userEmail: e.target.value })}
/>
<Button sx={{ bg: 'blue', mt: [2, 3], py: 3 }} type="submit">{`Finish ${
11 - Object.values(values).filter(n => n !== '').length
} fields to apply`}</Button>
</Base>
)
}

View file

@ -12,41 +12,42 @@ import {
import { Fade } from 'react-reveal'
import Timeline from './timeline'
import Stats from './stats'
import Signup from './signup'
import ApplyButton from './apply-button'
export default function Start() {
return (
<>
<Box
as="section"
id="apply"
sx={{
pt: 6,
zIndex: -999
}}
>
<Container
px={3}
mb={[4, 5]}
sx={{
display: 'flex',
flexDirection: 'column',
textAlign: 'center',
justifyContent: 'center'
}}
>
<Heading variant="ultratitle" color="white" mb={2}>
Sign up for Hack&nbsp;Club&nbsp;Bank.
</Heading>
<Container variant="narrow" sx={{ color: 'muted' }}>
<Text variant="lead">
Open to all registered Hack Clubs, hackathons, and charitable
organizations in the US and Canada.
<Box as="section" id="apply" py={6}>
<Flex sx={{ flexDirection: 'column', alignItems: 'center', gap: 5, mx: 4 }}>
<Flex
sx={{
flexDirection: 'column',
textAlign: 'center',
gap: 3,
}}
>
<Heading variant="ultratitle" color="white">
Sign up for Hack&nbsp;Club&nbsp;Bank.
</Heading>
<Text color='muted' variant='lead' m='0 !important'>
Open to Hack Clubs, hackathons, and charitable organizations in the US and Canada.
</Text>
</Container>
</Container>
<Timeline />
<Grid mt={[4, 5]} mb={[3, 4]} px={3} columns={[1, 1, '1fr 1fr']}>
</Flex>
<Stats />
<Timeline />
<Flex sx={{ flexDirection: 'column', textAlign: 'center', gap: 4, mx: 3 }}>
<ApplyButton />
<Text color='muted' sx={{ fontSize: 18 }}>We run Hack Club HQ on Bank!{' '}
<Link
href='https://bank.hackclub.com/hq'
color='primary'
>
See&nbsp;our&nbsp;finances.
</Link>
</Text>
</Flex>
</Flex>
{/* <Grid mt={[4, 5]} mb={[3, 4]} px={3} columns={[1, 1, '1fr 1fr']}>
<Fade bottom>
<Card
variant="primary"
@ -64,14 +65,7 @@ export default function Start() {
</Card>
</Fade>
<Container variant="narrow" sx={{ pr: [null, null, 2, 6], m: 0 }}>
<Stats
color="smoke"
fontSize={[7, 8]}
my={[3, 4]}
px={0}
width="auto"
align="left"
/>
<Text
sx={{
fontSize: 18,
@ -103,7 +97,7 @@ export default function Start() {
Hack Club does not directly provide banking services. Banking services
provided by FDIC-certified financial institutions.
</Text>
</Container>
</Container> */}
</Box>
</>
)

View file

@ -1,80 +1,96 @@
import { Text, Box } from 'theme-ui'
import { keyframes } from '@emotion/react'
import { timeSince } from '../../lib/helpers'
import useSWR from 'swr'
import Stat from '../stat'
import fetcher from '../../lib/fetcher'
import { Text, Box, Flex } from 'theme-ui'
import { useEffect, useState } from 'react'
const renderMoney = amount =>
Math.floor(amount / 100)
.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
})
.replace('.00', '')
const easeInOutExpo = (x) =>
x === 0
? 0
: x === 1
? 1
: x < 0.5
? Math.pow(2, 20 * x - 10) / 2
: (2 - Math.pow(2, -20 * x + 10)) / 2;
function startMoneyAnimation(setBalance, amount, duration = 2_000, moneyFormatter) {
const startTime = performance.now();
const flashing = keyframes({
from: { opacity: 0 },
'50%': { opacity: 1 },
to: { opacity: 0 }
})
function animate() {
const time = performance.now() - startTime;
const progress = time / duration;
const easedProgress = easeInOutExpo(progress);
function Dot() {
return (
<Text
sx={{
bg: 'green',
color: 'white',
borderRadius: 'circle',
display: 'inline-block',
lineHeight: 0,
width: '.4em',
height: '.4em',
marginRight: '.4em',
marginBottom: '.12em',
animationName: `${flashing}`,
animationDuration: '3s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite'
}}
/>
)
setBalance(moneyFormatter(amount * easedProgress))
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setBalance(moneyFormatter(amount))
}
}
requestAnimationFrame(animate);
}
function formatMoney(amount) {
const normalisedAmount = amount / 100
return normalisedAmount
.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
.split('.')
}
const Stats = props => {
const { data } = useSWR('https://bank.hackclub.com/stats', fetcher, {
fallbackData: {
transactions_volume: 500 * 1000 * 1000,
raised: 200 * 1000 * 500,
last_transaction_date: Date.now()
const Stats = () => {
const [transactedRaw, setTransactedRaw] = useState() // The raw amount transacted (in cents).
const [balance, setBalance] = useState(0) // A formatted balance string, split by decimal
useEffect(() => {
if (!transactedRaw) {
fetch('https://bank.hackclub.com/stats')
.then(res => res.json())
.then(data => setTransactedRaw(data.transactions_volume))
.catch(err => {
console.error(err)
setTransactedRaw(830796389)
})
}
})
let observer = new IntersectionObserver(
(e) => {
if (e[0].isIntersecting) {
console.info('intersecting')
startMoneyAnimation(setBalance, transactedRaw, 2_500, formatMoney)
}
},
{ threshold: 1.0 }
);
observer.observe(document.querySelector("#parent"));
return () => observer.disconnect();
}, [transactedRaw])
return (
<Box>
<Text
variant="lead"
fontSize={[2, 3]}
color="muted"
mt={[2, 4]}
mb={[2, 3]}
>
<Dot />
As of {timeSince(data?.last_transaction_date * 1000, false, true)}...
</Text>
<Box>
<Stat
{...props}
value={renderMoney(data?.raised)}
label="raised on Hack Club Bank"
/>
<Stat
{...props}
fontSize={[3, 4, 5]}
value={renderMoney(data?.transactions_volume)}
label="total amount transacted"
/>
</Box>
<Box id='parent'>
<Flex sx={{ flexDirection: 'column', alignItems: 'center' }}>
<Text sx={{ fontSize: [3, 4] }}>
So far we have enabled
</Text>
{ transactedRaw ?
<>
<Text variant='title' color='green' sx={{
color: 'green',
fontSize: [5, 6]
}}
>
{ balance[0] }
<Text sx={{ fontSize: [3, 4] }}>.{ balance[1] }</Text>
</Text>
</>
:
<Text variant='title' color='green' sx={{
color: 'green',
fontSize: [5, 6]
}}>...</Text>
}
<Text sx={{ fontSize: [3, 4] }}>in transactions</Text>
</Flex>
</Box>
)
}

View file

@ -1,147 +1,55 @@
import { Box, Flex, Container, Text, Badge, Link } from 'theme-ui'
import { Slide } from 'react-reveal'
import Icon from '../icon'
import { Flex, Text, Image, Box, Container } from 'theme-ui'
import Slide from 'react-reveal'
function Timeline({ children }) {
function Step({ stepIndex, label }) {
return (
<Flex
sx={{ flexDirection: ['column', null, 'row'], justifyContent: 'center' }}
>
{children}
<Flex sx={{
flexDirection: ['row', null, 'column'],
flex: '1 0 0',
alignItems: 'center',
maxWidth: ['24rem', null, '12rem'],
gap: 3
}}>
<Image src={`/bank/timeline-steps/step${stepIndex}.svg`} sx={{ flexShrink: 0 }} />
<Text variant='lead' sx={{
textAlign: ['left', null, 'center'],
margin: '0px !important'
}}>{label}</Text>
</Flex>
)
}
function TimelineStep({ children }) {
return (
<Flex
sx={{
marginX: [4, null, null],
paddingX: [null, null, 3, 4],
paddingY: [4, null, 0],
flexDirection: ['row', null, 'column'],
alignItems: 'center',
'&:before': {
content: '""',
background: '#3c4858',
height: ['420px', null, '4px'],
width: ['4px', null, '48%'],
marginLeft: [26, null, 0],
marginTop: [null, null, '34px'],
position: 'absolute',
zIndex: -1
},
'&:first-of-type:before': {
top: [0, null, 'auto'],
width: [0, null, 0],
left: [0, null, 0]
},
'&:last-of-type:before': {
bottom: [0, null, 'auto'],
left: [null, null, 0],
width: [null, null, 0]
}
}}
>
{children}
</Flex>
)
}
export default function Timeline() {
const labels = [
'Register your organization for Bank',
'Explore the interface in Playground mode',
'Hop on an intro call with the Bank team',
'Start fundraising!'
]
const stepSideLength = 64;
function Circle({ children }) {
return (
<Box
style={{
padding: 12,
background: 'red',
color: 'white',
backgroundImage:
'radial-gradient(ellipse farthest-corner at top left, #ff8c37, #ec3750)',
borderRadius: '100%',
display: 'inline-block',
lineHeight: 0,
position: 'relative',
zIndex: 999
}}
>
{children}
</Box>
)
}
function Step({ icon, name, duration, href }) {
return (
<TimelineStep pb={4}>
<Slide left>
<Circle>
{href ? (
<Link href={href} sx={{ cursor: 'pointer' }}>
<Icon glyph={icon} size={48} color="white" />
</Link>
) : (
<Icon glyph={icon} size={48} />
)}
</Circle>
<Container
sx={{
marginTop: 3,
display: 'flex',
justifyContent: ['left', null, 'center'],
flexDirection: 'column',
textAlign: ['left', null, 'center']
}}
>
<Badge
variant="pill"
sx={{
bg: 'muted',
color: 'darker',
fontWeight: 'normal',
textTransform: 'uppercase',
width: 64,
fontSize: 18,
px: 2,
mx: [null, null, 'auto']
}}
>
{duration}
</Badge>
<Text
sx={{ color: 'white', fontSize: 24, maxWidth: [300, null, 550] }}
>
{name}
</Text>
</Container>
</Slide>
</TimelineStep>
)
}
export default function RealTimeline() {
return (
<Timeline px={3}>
<Step
icon="send"
name="Submit an application for your organization"
duration="Step 1"
href="/bank/apply"
/>
<Step
icon="welcome"
name="Hop on an intro call with the Bank team"
duration="Step 2"
/>
<Step
icon="post"
name="Sign the contract &amp; unlock full access"
duration="Step 3"
/>
<Step
icon="friend"
name="Invite your team &amp; start fundraising"
duration="Step 4"
mb={0}
/>
</Timeline>
<Slide>
<Flex sx={{
flexDirection: ['column', null, 'row'],
justifyContent: 'space-between',
gap: 4,
maxWidth: ['300px', null, '1200px'],
mx: 'auto',
position: 'relative'
}}>
{labels.map((label, idx) => <Step stepIndex={idx + 1} label={label} key={idx} />)}
<Box sx={{
border: 'solid #8492a6',
borderWidth: '3px 3px 0 0',
position: 'absolute',
top: stepSideLength / 2,
left: '10%', // TODO: make this dynamic
right: ['auto', null, '10%'],
bottom: [stepSideLength / 2, null, 'auto'],
zIndex: -1,
}} />
</Flex>
</Slide>
)
}

9
components/flex-col.js Normal file
View file

@ -0,0 +1,9 @@
import { Flex } from 'theme-ui'
export default function FlexCol({ children, ...props }) {
return (
<Flex sx={{ flexDirection: 'column', ...props }}>
{ children }
</Flex>
)
}

View file

@ -151,7 +151,7 @@ const Footer = ({ dark = false, children, ...props }) => (
/>
<Service
href="https://figma.com/@hackclub"
icon="figma"
icon="figma-fill"
name="Figma"
/>
<Service
@ -169,7 +169,7 @@ const Footer = ({ dark = false, children, ...props }) => (
icon="instagram"
name="Instagram"
/>
<Service href="mailto:team@hackclub.com" icon="email" />
<Service href="mailto:team@hackclub.com" icon="email-fill" />
</Grid>
<Text my={2}>
<Link href="tel:1-855-625-HACK">1-855-625-HACK</Link>

View file

@ -1,6 +1,5 @@
/** @jsxRuntime classic */
/** @jsx jsx */
import React from 'react'
import { jsx } from 'theme-ui'
import Icon from '@hackclub/icons'

View file

@ -34,7 +34,7 @@ function Game({ game, gameImage, gameImage1, ...props }) {
>
<Box
as="a"
href={`https://editor.sprig.hackclub.com/?file=https://raw.githubusercontent.com/hackclub/sprig/main/games/${game.filename}.js`}
href={`https://editor.sprig.hackclub.com/?file=https://raw.githubusercontent.com/hackclub/sprig/main/games/${game?.filename}.js`}
target="_blank"
rel="noopener noreferrer"
sx={{
@ -129,7 +129,7 @@ function Game({ game, gameImage, gameImage1, ...props }) {
lineHeight: '1.4rem'
}}
>
{game.title}
{game?.title}
</Text>
<Text
as="h4"
@ -146,7 +146,7 @@ function Game({ game, gameImage, gameImage1, ...props }) {
lineHeight: '1rem'
}}
>
by {game.author}
by {game?.author}
</Text>
<Text
as="span"
@ -159,7 +159,7 @@ function Game({ game, gameImage, gameImage1, ...props }) {
mb: 1
}}
>
<RelativeTime value={game.addedOn} titleFormat="YYYY-MM-DD" />
<RelativeTime value={game?.addedOn} titleFormat="YYYY-MM-DD" />
</Text>
</Box>
</Box>
@ -210,6 +210,7 @@ export default function Sprig({ stars, game, gameImage, gameImage1 }) {
Build a Sprig game
</Buttons>
<Buttons
content="learn more on our github"
id="8"
link="https://github.com/hackclub/sprig"

View file

@ -0,0 +1,81 @@
async function getOrRefreshToken() {
// Get the token from localStorage
const token = JSON.parse(localStorage.getItem('mapkit-token'))
if (token) {
// If the token is still valid, return it
if (Date.now() < token.refreshBefore) return token.accessToken
} else {
// The token is invalid or doesn't exist, so get a new one
// This is a MapKit master token, restricted to https://hackclub.com
const master = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkNSQkg2S1VLTEIifQ.eyJpc3MiOiJQNlBWMlI5NDQzIiwiaWF0IjoxNjc5NjY3NjIyLCJleHAiOjE3MTEyMzg0MDB9.E6g9QPdbEWLgF6tdcL0YfX8NescYnwKhQpXdiyRitNm7-Oot-3VH0ze9xUd8xkOzuS_-7KeWy4bfYTD-2yX7Sg';
// Get a MapKit server token
const res = await fetch('https://maps-api.apple.com/v1/token', {
headers: { Authorization: `Bearer ${master}` },
})
const resJson = await res.json()
// Set the token's expiration time to 10 seconds before the actual expiration time
resJson.refreshBefore = Date.now() + (resJson.expiresInSeconds * 1_000) - 10_000
// Save the token to localStorage
localStorage.setItem('mapkit-token', JSON.stringify(resJson))
return resJson.accessToken
}
}
//TODO: Limit the number of retries
export async function search(query) {
if (!query.trim()) return
const token = await getOrRefreshToken()
const res = await fetch(`https://maps-api.apple.com/v1/search?q=${query}`, {
headers: { Authorization: `Bearer ${token}` },
})
const resJson = await res.json()
if (resJson.error) {
if (resJson.error.message === 'Not Authorized') {
// The token is invalid, so remove it from localStorage
localStorage.removeItem('mapkit-token')
// Try again
console.warn('MapKit token expired, refreshing')
return search(query)
} else {
throw new Error(resJson.error.message)
}
}
return resJson
}
export async function geocode(query) {
if (!query.trim()) return
const token = await getOrRefreshToken()
const res = await fetch(`https://maps-api.apple.com/v1/geocode?q=${query}`, {
headers: { Authorization: `Bearer ${token}` },
})
const resJson = await res.json()
if (resJson.error) {
if (resJson.error.message === 'Not Authorized') {
// The token is invalid, so remove it from localStorage
localStorage.removeItem('mapkit-token')
// Try again
console.warn('MapKit token expired, refreshing')
return geocode(query)
} else {
throw new Error(resJson.error.message)
}
}
return resJson
}

View file

@ -36,6 +36,7 @@
"geopattern": "^1.2.3",
"globby": "^11.0.4",
"graphql": "^16.6.0",
"js-confetti": "^0.11.0",
"lodash": "^4.17.21",
"next": "^12.3.1",
"next-compose-plugins": "^2.2.1",

View file

@ -18,14 +18,12 @@ export default async function handler(req, res) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.HCB_API_TOKEN}`
Authorization: `Bearer ${process.env.HCB_API_TOKEN || ""}`
}
})
.then(r => r.json())
.then(async r => {
console.log(data)
const application = await applicationsTable.create({
await applicationsTable.create({
'First Name': data.firstName,
'Last Name': data.lastName,
'Email Address': data.userEmail,
@ -37,20 +35,19 @@ export default async function handler(req, res) {
'Mailing Address': data.mailingAddress,
'Address Line 1': data.addressLine1,
'Address Line 2': data.addressLine2,
City: data.addressCity,
State: data.addressState,
'City': data.addressCity,
'State': data.addressState,
'Zip Code': data.addressZip,
'Address Country': data.addressCountry,
Country: data.eventCountry,
'Country': data.eventCountry,
'Event Location': data.eventLocation,
'Have you used Hack Club Bank for any previous events?':
data.returningUser,
'Have you used Hack Club Bank for any previous events?': data.returningUser,
'How did you hear about HCB?': data.referredBy,
Transparent: data.transparent,
'Transparent': data.transparent,
'HCB account URL': `https://bank.hackclub.com/${r.slug}`
})
res.writeHead(302, { Location: '/bank/apply/success' }).end()
console.log(r.statusText)
console.log(r)
})
.catch(error => {
console.log(error)

View file

@ -6,7 +6,7 @@ export async function getGames() {
.sort((a, b) => new Date(b.addedOn) - new Date(a.addedOn))
.slice(0, 4)
return games
return []
}
export default async function Games(req, res) {

View file

@ -1,31 +1,133 @@
import BankApplyForm from '../../components/bank/form'
import { Box, Container, Card } from 'theme-ui'
import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/router'
import { Box, Flex, Text } from 'theme-ui'
import ForceTheme from '../../components/force-theme'
import Head from 'next/head'
import Meta from '@hackclub/meta'
import GeoPattern from 'geopattern'
import { useEffect } from 'react'
import FlexCol from '../../components/flex-col'
import Progress from '../../components/bank/apply/progress'
import NavButton from '../../components/bank/apply/nav-button'
import Watermark from '../../components/bank/apply/watermark'
import FormContainer from '../../components/bank/apply/form-container'
import BankInfo from '../../components/bank/apply/bank-info'
import OrganizationInfoForm from '../../components/bank/apply/org-form'
import PersonalInfoForm from '../../components/bank/apply/personal-form'
import AlertModal from '../../components/bank/apply/alert-modal'
import { search, geocode } from '../../lib/bank/apply/address-validation'
export default function Apply() {
const bg = GeoPattern.generate(new Date()).toDataUrl()
const router = useRouter()
const [step, setStep] = useState(1)
const formContainer = useRef()
const [formError, setFormError] = useState(null)
const requiredFields = [
[],
['eventName', 'eventLocation'],
['firstName', 'lastName', 'userEmail']
]
useEffect(() => {
console.log(`Form error: ${formError}`)
if (!router.isReady) return
setStep(parseInt(router.query.step))
// Set the query url parameter to 1 if it's not present
if (!step || step < 1) {
router.push({
pathname: router.pathname,
query: { ...router.query, step: 1 } },
undefined,
{}
)
}
})
return (
<>
<script
async
src='https://maps.googleapis.com/maps/api/js?key=AIzaSyApxZZ8-Eh_6RgHUu8-BAOpx3xhfF2yK9U&libraries=places&mapInit=foo'
></script>
<Meta as={Head} title="Apply for Hack Club Bank" />
<ForceTheme theme="dark" />
<Box
sx={{
backgroundImage: bg,
py: 4,
backgroundAttachment: 'fixed'
display: 'grid',
gap: 5,
gridTemplateAreas: [
'"title" "form" "form" "nav"',
null,
null,
'"title form" "title form" "nav form"',
],
height: ['auto', null, null, '100vh'],
p: [4, 5]
}}
>
<ForceTheme theme="dark" />
<Container variant="copy">
<Card variant="primary">
<BankApplyForm />
</Card>
</Container>
<Box sx={{ gridArea: 'title' }}>
<FlexCol gap={[4, null, null, '20vh']}>
<Text variant='title'>Let's get you<br />set up on bank.</Text>
<Progress />
</FlexCol>
</Box>
<Box sx={{ gridArea: 'form', overflowY: 'auto' }}>
<FormContainer ref={formContainer}>
{ step === 1 && <BankInfo /> }
{ step === 2 && <OrganizationInfoForm requiredFields={requiredFields} /> }
{ step === 3 && <PersonalInfoForm requiredFields={requiredFields} /> }
</FormContainer>
</Box>
<Flex
sx={{
gridArea: 'nav',
alignSelf: 'end',
alignItems: 'flex-end',
justifyContent: 'space-between',
}}
>
<NavButton isBack={true} form={formContainer} />
<NavButton
isBack={false}
form={formContainer}
setFormError={setFormError}
requiredFields={requiredFields}
clickHandler={async () => {
//TODO: Put this somewhere else
// Validate the address
if (step === 3) {
// Get the raw personal address input
const userAddress = sessionStorage.getItem('bank-signup-userAddressRaw')
if (!userAddress) return
const result = await geocode(userAddress)
const addrComp = (type) =>
result.results[0].structuredAddress[type]
const thoroughfare = addrComp('fullThoroughfare')
const city = addrComp('locality')
const state = addrComp('administrativeArea')
const postalCode = addrComp('postal_code')
const country = result.results[0].country
const countryCode = result.results[0].countryCode
sessionStorage.setItem('bank-signup-addressLine1', thoroughfare)
sessionStorage.setItem('bank-signup-addressCity', city ?? '')
sessionStorage.setItem('bank-signup-addressState', state ?? '')
sessionStorage.setItem('bank-signup-addressZip', postalCode ?? '')
sessionStorage.setItem('bank-signup-addressCountry', country ?? '')
sessionStorage.setItem('bank-signup-addressCountryCode', countryCode ?? '')
}
}}
/>
</Flex>
</Box>
<AlertModal formError={formError} setFormError={setFormError} />
<Watermark />
</>
)
}

View file

@ -0,0 +1,477 @@
import { Box, Card, Container, Flex, Link, Text } from 'theme-ui'
import { useEffect, useRef, useState } from 'react'
import { keyframes } from '@emotion/react'
import FlexCol from '../../components/flex-col'
import Meta from '@hackclub/meta'
import Head from 'next/head'
import ForceTheme from '../../components/force-theme'
import Nav from '../../components/nav'
import Footer from '../../components/footer'
import Icon from '../../components/icon'
import Tilt from '../../components/tilt'
function Bullet({ glow=true, icon, href, children }) {
let effectColours = [
'#ec3750',
'#ff8c37',
'#f1c40f',
'#33d6a6',
'#5bc0de',
'#338eda',
'#a633d6',
]
// Raw doggen this trig, no AI this is natty
function keyframeGenerator(spread, blur, colours, opacity = 0.5) {
let hexOpacity =
Math.max(Math.min(Math.round(opacity * 255), 255), 0).toString(16).padStart(2, '0');
let final = {}
for (let i = 0; i <= 100; i++) {
let baseX = Math.sin(i * Math.PI / 50) // 50 keyframes for each pi radians
let baseY = -Math.cos(i * Math.PI / 50)
// Ensure no scientific notation
const roundFactor = 1_000_000
baseX = Math.round(baseX * roundFactor) / roundFactor
baseY = Math.round(baseY * roundFactor) / roundFactor
let boxShadow = ''
for (let c = 0; c < colours.length; c++) {
// Rotate by 2pi / colours.length * c radians
let x = baseX * Math.cos(2 * Math.PI * c / colours.length) - baseY * Math.sin(2 * Math.PI * c / colours.length)
let y = baseX * Math.sin(2 * Math.PI * c / colours.length) + baseY * Math.cos(2 * Math.PI * c / colours.length)
boxShadow += `${x * spread}px ${y * spread}px ${blur}px ${colours[c]}${hexOpacity},`
}
// Remove trailing comma
boxShadow = boxShadow.slice(0, -1)
final[i + '%'] = { boxShadow }
}
return final
}
const shadowSpread = glow ? 5 : 0
const shadowBlur = glow ? 10 : 0
const animatedBoxShadow = keyframes(keyframeGenerator(shadowSpread, shadowBlur, effectColours))
const borderWidth = '2px'
return (
<Tilt>
<Flex
as='a'
{...href && { href }}
target='_blank'
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
width: '20rem',
borderWidth,
borderRadius: '8px !important',
p: '8px !important',
textDecoration: 'none',
color: 'inherit',
backgroundColor: 'var(--theme-ui-colors-darkless)',
'&:hover::after': {
content: '""',
position: 'absolute',
inset: 0,
boxShadow: `linear-gradient(60deg, ${effectColours[0]}, ${effectColours[1]}, ${effectColours[2]}, ${effectColours[3]}, ${effectColours[4]})`,
borderRadius: 'inherit',
zIndex: -1,
animation: `${animatedBoxShadow} 5s ease infinite`,
}
}}
>
<Icon glyph={icon} size={42} sx={{ color: 'red', flexShrink: 0 }} />
<Box sx={{ textAlign: 'left' }}>{children}</Box>
{ href &&
<Icon glyph='external-fill' size={32} sx={{ position: 'absolute', top: 0, right: 0, translate: '50% -50%', color: 'muted' }} />
}
</Flex>
</Tilt>
)
}
function BulletBox({ padding = '2rem', children }) {
return (
<Box
as='ul'
sx={{
width: '100%',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gridGap: '2rem',
padding,
}}
>
{ children }
</Box>
)
}
function Section({ id, children }) {
return (
<Flex as='section' id={id} sx={{ flexDirection: 'column', pt: 5 }}>
{ children }
</Flex>
)
}
export default function FiscalSponsorship() {
const gridRef = useRef()
const glowRef = useRef()
const scrollPos = 0
const mousePos = [0, 0]
const setGlowMaskPosition = () => {
const finalPos = [-mousePos[0], -mousePos[1] + scrollPos]
glowRef.current.style.maskPosition = `${finalPos[0]}px ${finalPos[1]}px`;
glowRef.current.style.WebkitMaskPosition = `${finalPos[0]}px ${finalPos[1]}px`;
}
useEffect(() => {
const handleScroll = (e) => {
const s = -window.scrollY / 10
gridRef.current.style.transform = `translateY(${s}px)`
scrollPos = s
setGlowMaskPosition()
}
const handleMouseMove = (e) => {
const x = e.clientX
const y = e.clientY
glowRef.current.style.left = x + 'px'
glowRef.current.style.top = y + 'px'
mousePos = [x, y]
setGlowMaskPosition()
}
window.addEventListener('scroll', handleScroll)
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return (
<Box as="main" key="main" sx={{ position: 'relative' }}>
<style>
{`*{
scroll-behavior: smooth;
}`}
</style>
<Box
ref={gridRef}
sx={{
position: 'fixed',
inset: 0,
height: '1000%',
zIndex: -2,
backgroundSize: '20px 20px',
backgroundImage: `linear-gradient(to right, #23262D 1px, transparent 1px),
linear-gradient(to bottom, #23262D 1px, transparent 1px) `,
backgroundPosition: 'top left',
maskImage: `linear-gradient(180deg, transparent 0%, white 3%)`,
}}
/>
<Box
ref={glowRef}
sx={{
pointerEvents: 'none',
zIndex: -2,
position: 'fixed',
top: '0',
left: '0',
width: '20rem',
height: '20rem',
background: 'red',
opacity: 0.2,
borderRadius: '50%',
filter: 'blur(2rem)',
translate: '-50% -50%',
// Mask it to the grid background
maskImage: `linear-gradient(to right, #23262D 1px, transparent 1px),
linear-gradient(to bottom, #23262D 1px, transparent 1px) `,
maskSize: '20px 20px, 20px 20px, cover',
maskPosition: '0px 0px',
}}
/>
<Nav dark />
<ForceTheme theme="dark" />
<Meta
as={Head}
title="Fiscal Sponsorship"
description="What is fiscal sponsorship?"
image="/bank/og-image.png"
>
<title>Fiscal Sponsorship Hack&nbsp;Club&nbsp;Bank</title>
</Meta>
<Container sx={{ textAlign: 'center', mt: 6 }}>
<FlexCol gap={4} alignItems='center'>
<FlexCol gap={4} alignItems='center'>
<Text variant='ultratitle'>Fiscal sponsorship</Text>
<Text variant='title'>
The fast track to <Text sx={{ color: 'red', textShadow: '0 0 50px var(--theme-ui-colors-red)' }}>501(c)(3)&nbsp;nonprofit </Text>
status for your startup
</Text>
</FlexCol>
<Text variant='headline'>
Building a project, event, or organization on a
mission to serve the public good or your community?
Obtaining 501(c)(3) public charity status in the U.S.
just got easier through fiscal sponsorship.
</Text>
<FlexCol gap={1} alignItems='center'>
<Text variant='headline'>Jump to:</Text>
<BulletBox padding='0'>
<Link sx={{fontSize: 2 }} href='#what-is'>Whats Fiscal Sponsorship?</Link>
<Link sx={{fontSize: 2 }} href='#requirements'>Requirements for Fiscal Sponsorship</Link>
<Link sx={{fontSize: 2 }} href='#partner'>Partner with Hack&nbsp;Club&nbsp;Bank</Link>
</BulletBox>
</FlexCol>
<>
<Text variant='lead'>
Every year, thousands of organizations from around
the world apply through the IRS to become a U.S. 501(c)(3)
for charitable recognition and tax exemptions.
The process for obtaining legal status can take anywhere
from 2-12 months, and as a nonprofit organizer,
its important to know that this can mean:
</Text>
<BulletBox>
<Bullet glow={false} icon='sad'>
$3,000 in up-front costs, from filing different
applications and forms to support from legal counsel.
</Bullet>
<Bullet glow={false} icon='sad'>
The potential for the IRS to reject an application
(and not return your money).
</Bullet>
<Bullet glow={false} icon='sad'>
An annual filing fee of $2,500.
</Bullet>
<Bullet glow={false} icon='sad'>
Hiring bookkeepers and accountants to prepare
taxes and provide upkeep to stay in good standing.
</Bullet>
<Bullet glow={false} icon='sad'>
Up to $5,000 to close down shop if you lose or terminate status.
</Bullet>
</BulletBox>
</>
<Text variant='lead'>
Unfortunately, between the price and time needed to
organize a nonprofit, these barriers prevent many
charitable initiatives from exiting an idea phase
to go on to making a valuable impact throughout the world.
</Text>
<Text variant='lead'>
So 501(c)(3) status is cool and all,
but why go through the hassle of applying
when its so expensive and time consuming?
</Text>
<Text variant='headline'>
As a legally recognized 501(c)(3) nonprofit
in the U.S., there are loads of legal tax
benefits that make the status worth it, like:
</Text>
<BulletBox>
<Bullet icon='payment'>
The ability to receive <b>tax deductible
donations</b> from sponsors.
</Bullet>
<Bullet icon='member-add'>
Reduced taxable income for your U.S.
supporters, which <b>incentivizes giving</b>.
</Bullet>
<Bullet icon='leader'>
<b>Exemption</b> from U.S. federal
income tax and unemployment tax.
</Bullet>
<Bullet icon='bolt'>
Potential exemption from state
income, sales, and employment taxes.
</Bullet>
<Bullet icon='email'>
Potential for reduced rates on postage,
marketing, advertising, legal counsel, and more.
</Bullet>
</BulletBox>
<Text variant='lead'>
As it turns out, theres an alternative route
for startups, student-led initiatives,
or anyone looking to avoid a headache
with the IRS to obtain all the benefits of
501(c)(3) status. To avoid the traditional
filing route, go for fiscal sponsorship.
</Text>
<Section id='what-is'>
<Text variant='title'>
Whats Fiscal Sponsorship?
</Text>
<Text variant='lead'>
By legally partnering with an existing
nonprofit offering fiscal sponsorship,
projects and events can claim all the
legal benefits of individual 501(c)(3)
status. Piggy-backing off this existing
status, organizations also gain access
to resources from their fiscal sponsor like:
</Text>
<BulletBox>
<Bullet icon='docs'>
Bookkeeping and administration ensuring
that all paperwork and taxes are filed.
</Bullet>
<Bullet icon='bag'>
Fully established HR and benefits,
which can vary by the fiscal sponsor.
</Bullet>
<Bullet icon='admin'>
A board of directors - you
dont have to organize your own!
</Bullet>
<Bullet icon='payment'>
Fully transparent operational fees,
typically ranging from 7-12% that
prevent you from paying operating costs.
</Bullet>
<Bullet icon='door-leave'>
The ability to terminate your fiscal
sponsorship agreement and file for
separate tax-exempt status at any point.
</Bullet>
</BulletBox>
<Text variant='lead'>
If youre brand new to nonprofit organizing
or unsure where your project will take you,
fiscal sponsorship is a great tool to help
manage your finances and gauge whether becoming
an independent nonprofit down the line is
practical or financially feasible.
</Text>
<Text variant='lead'>
Fiscal sponsorship makes it so that hacks
arent just for folding that stubborn fitted
sheet or sending an email to hundreds of
people in seconds; theyre also for obtaining
nonprofit status.
</Text>
</Section>
<Section id='requirements'>
<Text variant='title'>
Requirements for Fiscal Sponsorship
</Text>
<Text variant='lead'>
Depending on the fiscal sponsor you choose,
requirements for working together can vary.
Fiscal sponsors generally ask that your
nonprofits goals be similar to theirs.
They usually also ask that your organization
or event commits to remaining charitable in
nature and refrains from activites that may
result in loss of 501(c)(3) status.
</Text>
</Section>
<Section id='partner'>
<Text variant='title'>
Partner with Hack&nbsp;Club&nbsp;Bank
</Text>
<Text variant='lead'>
While many fiscal sponsors require that their
partners relate to their mission in similar ways,
at Hack&nbsp;Club&nbsp;Bank, weve built our infrastructure
to support hundreds of causes in all areas of
charitability. Check out some of the resources
weve built our fiscal sponsorship foundation on:
</Text>
<BulletBox>
<Bullet icon='bank-account'>
A web interface that looks and operates
just like a bank account
</Bullet>
<Bullet icon='card'>
Fee-free invoicing, ACH or check
transfers, and reimbursements
</Bullet>
<Bullet icon='link'>
A customizable and embeddable
donations URL
</Bullet>
<Bullet icon='google'>
Perks like Google Workspace,
PVSA certification, and 1Password credits
</Bullet>
<Bullet icon='purse' href='https://bank.hackclub.com/hq'>
Optional transparency mode to show
off finances to donors and the public
</Bullet>
<Bullet icon='friend'>
24 hour weekday turnaround time from a
full-time support team for all queries
</Bullet>
<Bullet icon='everything' href='http://hackclub.com/bank'>
...and more!
</Bullet>
</BulletBox>
<Text variant='lead'>
Looking for nonprofit status and not a religious or
political organization? Wed love to meet you and
chat about working together. Feel free to apply
here or email our team if you have more questions
about fiscal sponsorship!
</Text>
</Section>
<Text variant='lead'>
At its core, Hack Club is a nonprofit encouraging
students to learn how to code by building and making
cool things. Hack&nbsp;Club&nbsp;Bank was built out by teenagers
at Hack Club and continues to be a real-world space
that teens can hack on every day.
</Text>
</FlexCol>
</Container>
<Box sx={{
height: '100px',
position: 'relative',
width: '100%',
overflow: 'hidden',
'&::after': {
content: '""',
width: '500%',
height: '100%',
position: 'absolute',
translate: '-50% 100%',
boxShadow: '0 -64px 64px #17171d',
}
}}/>
<Footer dark />
</Box>
)
}

View file

@ -12,6 +12,9 @@ import Start from '../../components/bank/start'
import Nonprofits from '../../components/bank/nonprofits'
const styles = `
html {
scroll-behavior: smooth;
}
::selection {
background-color: #e42d42;
color: #ffffff;

View file

@ -1,77 +1,119 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { Container, Text, Flex, Link, Image } from 'theme-ui'
import { useEffect } from 'react'
import { Box, Button, Card, Container, Divider, Text, Link, Flex, Image } from 'theme-ui'
import ForceTheme from '../../components/force-theme'
import JSConfetti from 'js-confetti'
import Icon from '../../components/icon'
import FlexCol from '../../components/flex-col'
function Option({ icon, label, link }) {
const color =
icon === 'email' ? '#338eda' : icon === 'slack' ? '#a633d6' : '#ec3750';
return (
<Button
variant='outline'
as='a'
href={link}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: 'fit-content',
color,
borderColor: color
}}
>
<Flex sx={{ alignItems: 'center', gap: [0, null, 1], px: 2 }}>
<Icon glyph={icon} sx={{ width: [32, null, 46], height: [32, null, 46] }} />
<Text sx={{ fontSize: [2, null, 3] }}>{ label }</Text>
</Flex>
</Button>
)
}
export default function ApplicationSuccess() {
const router = useRouter()
const [counter, setCounter] = useState(15)
const interval = useRef()
useEffect(() => {
interval.current = setInterval(() => {
setCounter(c => {
if (c <= 1) {
clearInterval(interval.current)
router.push('/bank')
return 0
} else {
return c - 1
}
})
}, 1000)
return () => {
clearInterval(interval.current)
}
const jsConfetti = new JSConfetti()
jsConfetti.addConfetti({
confettiColors: [ // Hack Club colours!
'#ec3750',
'#ff8c37',
'#f1c40f',
'#33d6a6',
'#5bc0de',
'#338eda',
'#a633d6',
],
})
}, [router])
const cancelRedirect = () => {
clearInterval(interval.current)
setCounter(null)
}
return (
<Container variant="copy">
<ForceTheme theme="dark" />
<Flex
sx={{
flexDirection: 'column',
justifyContent: 'center',
textAlign: 'center',
pt: 6
}}
<FlexCol
height="100vh"
textAlign="center"
alignItems="center"
justifyContent="space-between"
py={5}
gap={4}
>
<Image
src="https://cloud-5mgrt4f4s-hack-club-bot.vercel.app/0frame.svg"
alt="Dinosaur partying"
sx={{ width: '40%', mx: 'auto', mb: 4 }}
/>
<Text variant="title" sx={{ textAlign: 'center' }}>
Thanks for applying!
</Text>
<Text variant="lead">
Well reach out to schedule your introductory call within 24 hours on
weekdays. If you have any questions about your application, please
dont hesitate to reach out at{' '}
<Link as="a" href="mailto:bank@hackclub.com">
bank@hackclub.com
</Link>{' '}
or on the <Link href="/slack">Hack Club Slack</Link> in the{' '}
<strong>#bank</strong> channel.
</Text>
{counter !== null && (
<Flex sx={{ justifyContent: 'center' }}>
<Text sx={{ mr: 3 }}>Redirecting in {counter} seconds.</Text>
<Link sx={{ cursor: 'pointer' }} onClick={() => cancelRedirect()}>
Cancel
</Link>
<FlexCol gap={4} alignItems="center">
<Image
src="/bank/apply/party-orpheus.svg"
alt="Dinosaur partying"
sx={{ width: "40%" }}
/>
<FlexCol gap={2}>
<Text variant="title">Thanks for applying!</Text>
<Text variant="lead">
Head on over to Bank and explore the dashboard
</Text>
</FlexCol>
</FlexCol>
<FlexCol gap={4} width="100%">
<Text sx={{ fontSize: [3, null, 4] }}>
Questions about your application?
</Text>
<Flex
sx={{
flexDirection: ["column", null, "row"],
justifyContent: "space-evenly",
alignItems: "center",
gap: [3, null, 0],
}}
>
<Option icon="email" label="Mail" link="mailto:bank@hackclub.com">
bank@hackclub.com
</Option>
<Option
icon="slack"
label="Slack"
link="https://hackclub.slack.com/channels/bank"
>
#bank
</Option>
<Option
icon="help"
label="FAQ"
link="https://bank.hackclub.com/faq"
>
FAQ
</Option>
</Flex>
)}
</Flex>
</FlexCol>
<Button as="a" href="https://bank.hackclub.com">
<Flex sx={{ alignItems: "center", px: [2, null, 3], py: 2 }}>
<Icon glyph="bank-account" size={36} />
<Text sx={{ fontSize: 3 }}>Head to Bank!</Text>
</Flex>
</Button>
</FlexCol>
</Container>
)
);
}

View file

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 38C36.5 38 38 36.5 38 20C38 3.5 36.5 2 20 2C3.5 2 2 3.5 2 20C2 36.5 3.5 38 20 38Z" stroke="white" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M62.5 32C62.5 39.3388 62.331 44.8938 61.6443 49.1353C60.9591 53.3673 59.7873 56.0914 57.9393 57.9393C56.0914 59.7873 53.3673 60.9591 49.1353 61.6443C44.8938 62.331 39.3388 62.5 32 62.5C24.6612 62.5 19.1062 62.331 14.8647 61.6443C10.6327 60.9591 7.90865 59.7873 6.06066 57.9393C4.21267 56.0914 3.0409 53.3673 2.35572 49.1353C1.66901 44.8938 1.5 39.3388 1.5 32C1.5 24.6612 1.66901 19.1062 2.35572 14.8647C3.0409 10.6327 4.21267 7.90865 6.06066 6.06066C7.90865 4.21267 10.6327 3.0409 14.8647 2.35572C19.1062 1.66901 24.6612 1.5 32 1.5C39.3388 1.5 44.8938 1.66901 49.1353 2.35572C53.3673 3.0409 56.0914 4.21267 57.9393 6.06066C59.7873 7.90865 60.9591 10.6327 61.6443 14.8647C62.331 19.1062 62.5 24.6612 62.5 32Z" fill="#17171D" stroke="#8492A6" stroke-width="3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0683 19.9519V27.7557L30.8901 29.9623C31.8535 30.1587 32.372 30.9439 32.4073 31.6804C32.4073 31.6937 32.4088 31.7129 32.4088 31.7306C32.4088 31.7365 32.4088 31.7424 32.4088 31.7483C32.4088 31.7557 32.4088 31.7616 32.4088 31.7675C32.4088 31.7852 32.4073 31.8044 32.4073 31.8177C32.372 32.5528 31.8535 33.3395 30.8901 33.5358L20.0683 35.7425V44.0481C20.099 44.0496 20.1434 44.0451 20.2063 44.023C26.9351 41.7735 43.8474 32.4982 43.8474 31.7601C43.8474 31.0221 26.8568 22.2058 20.2063 19.9755C20.1434 19.9549 20.099 19.9504 20.0683 19.9519ZM47 31.7483V31.7321V31.7277C46.9831 30.6974 46.4017 29.8251 45.5809 29.2406C40.0105 25.273 27.92 19.4368 21.2158 17.1888C20.2585 16.867 19.2214 16.9556 18.3884 17.4619C17.5385 17.977 17 18.8759 17 19.9224V28.3535C17 29.3985 17.7609 30.3018 18.8241 30.5188L24.8548 31.7483L18.8241 32.9793C17.7609 33.1948 17 34.0982 17 35.1447V44.0776C17 45.1241 17.5385 46.0215 18.3884 46.5381C19.2214 47.0444 20.2585 47.133 21.2158 46.8112C27.9215 44.5573 40.0703 38.1764 45.5794 34.2576C46.4017 33.6716 46.9831 32.8007 46.9985 31.7705V31.766L47 31.7483Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,8 @@
<svg width="65" height="64" viewBox="0 0 65 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M62.8333 32C62.8333 39.3388 62.6642 44.8938 61.9775 49.1353C61.2924 53.3673 60.1206 56.0914 58.2726 57.9393C56.4246 59.7873 53.7005 60.9591 49.4685 61.6443C45.2271 62.331 39.672 62.5 32.3333 62.5C24.9945 62.5 19.4394 62.331 15.198 61.6443C10.966 60.9591 8.2419 59.7873 6.39391 57.9393C4.54593 56.0914 3.37415 53.3673 2.68897 49.1353C2.00226 44.8938 1.83325 39.3388 1.83325 32C1.83325 24.6612 2.00226 19.1062 2.68897 14.8647C3.37415 10.6327 4.54593 7.90865 6.39391 6.06066C8.2419 4.21267 10.966 3.0409 15.198 2.35572C19.4394 1.66901 24.9945 1.5 32.3333 1.5C39.672 1.5 45.2271 1.66901 49.4685 2.35572C53.7005 3.0409 56.4246 4.21267 58.2726 6.06066C60.1206 7.90865 61.2924 10.6327 61.9775 14.8647C62.6642 19.1062 62.8333 24.6612 62.8333 32Z" fill="#17171D" stroke="#8492A6" stroke-width="3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.954 32.0669C43.2604 33.0422 42.1883 33.6596 40.89 32.849L36.6569 35.2875C33.726 36.9761 32.0907 37.5273 30.8506 37.072L28.3597 38.5081C23.6704 41.2099 21.4516 38.8944 20.3767 36.868C19.3005 34.8417 18.2554 31.8494 22.9448 29.1475L25.4356 27.7128C25.6604 26.4132 26.9573 25.2757 29.8881 23.5871L34.2919 21.05C34.3189 19.4749 35.4195 18.8399 36.8315 18.0267C39.3955 16.5501 40.9347 15.6626 44.9959 22.6833C49.0572 29.7027 48.0311 30.2931 44.954 32.0669ZM41.4708 29.6744C40.8332 28.8746 40.1157 27.7588 39.1342 26.0606C37.2227 22.7549 36.9939 21.4837 36.9967 21.1257C37.1808 20.942 37.4502 20.788 37.9186 20.5205L37.9348 20.511C38.012 20.4664 38.0959 20.4192 38.1852 20.3665C38.2814 20.3124 38.3694 20.2611 38.4519 20.2125C38.9176 19.9423 39.1857 19.7869 39.4375 19.7194C39.7502 19.8964 40.7398 20.7285 42.6513 24.0342C43.6341 25.731 44.2446 26.909 44.6196 27.8601C44.8132 28.3288 44.8795 28.7139 44.9107 28.926C44.6941 29.0989 44.38 29.2799 43.8913 29.5595L43.8899 29.5609C43.7992 29.6122 43.7031 29.6676 43.6002 29.727C43.4974 29.7851 43.4012 29.8419 43.3119 29.8932C42.8232 30.1769 42.5091 30.3579 42.2505 30.4579C42.0827 30.3269 41.7808 30.0756 41.4708 29.6744ZM38.8202 30.9213L38.9596 30.8402C38.3355 29.9837 37.6275 28.8584 36.7895 27.4116C35.9516 25.9634 35.3518 24.7759 34.9511 23.7911L34.7589 23.9005L31.2419 25.9269C29.8529 26.7253 29.0393 27.263 28.4775 27.7871C28.2636 27.9668 28.1729 28.0951 28.135 28.1654V28.1667C28.1404 28.2397 28.1553 28.4437 28.3191 28.8084C28.5912 29.4515 29.0339 30.2174 29.6593 31.2982L29.8502 31.6292C30.477 32.7112 30.9183 33.4759 31.3407 34.0325C31.5762 34.3567 31.7455 34.4715 31.8064 34.5134H31.8077C31.8876 34.5161 32.0433 34.5012 32.3073 34.4067C33.0437 34.1838 33.9169 33.7474 35.3031 32.9477L38.8202 30.9213ZM26.099 30.4498L26.057 30.4741L24.2985 31.4887C22.406 32.5775 22.2151 33.3867 22.1785 33.6407C22.1176 34.077 22.2746 34.6714 22.7688 35.6036C23.183 36.3844 23.6081 36.7924 23.9926 36.9424C24.2579 37.045 25.1067 37.2612 27.006 36.1683L28.7645 35.1551L28.8065 35.1294C28.3828 34.4958 27.936 33.7217 27.4121 32.818L27.4108 32.8153C26.8869 31.9088 26.4388 31.1347 26.099 30.4498Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3844 40.5588C18.7373 40.9316 17.9102 40.7101 17.5352 40.0643L16.1814 37.7245C15.8078 37.0774 16.0298 36.252 16.6769 35.8778C17.3253 35.505 18.1525 35.7265 18.5275 36.3736L19.8812 38.7134C20.2549 39.3592 20.0328 40.1846 19.3844 40.5588Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.2968 37.892C35.5739 38.0852 35.1461 38.8269 35.3397 39.5469L37.0711 45.9976C37.2295 46.5893 37.7656 47 38.3788 47C39.2696 47 39.9167 46.157 39.6866 45.2992L37.9538 38.8485C37.7615 38.1271 37.0183 37.6989 36.2968 37.892Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.2816 45.6491C32.2816 46.3948 31.6751 47 30.9278 47C30.1792 47 29.5741 46.3948 29.5741 45.6491V41.5963C29.5741 40.8492 30.1792 40.2454 30.9278 40.2454C31.6751 40.2454 32.2816 40.8492 32.2816 41.5963V45.6491Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.8756 45.9976C25.7172 46.5893 25.1798 47 24.5679 47C23.6771 47 23.03 46.157 23.2602 45.2992L23.9032 42.9013C24.0968 42.1799 24.84 41.7516 25.5615 41.9448C26.2844 42.138 26.7122 42.8797 26.5186 43.5997L25.8756 45.9976Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="65" height="64" viewBox="0 0 65 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63.1665 32C63.1665 39.3388 62.9975 44.8938 62.3108 49.1353C61.6256 53.3673 60.4538 56.0914 58.6058 57.9393C56.7579 59.7873 54.0338 60.9591 49.8018 61.6443C45.5604 62.331 40.0053 62.5 32.6665 62.5C25.3278 62.5 19.7727 62.331 15.5312 61.6443C11.2992 60.9591 8.57515 59.7873 6.72716 57.9393C4.87918 56.0914 3.70741 53.3673 3.02222 49.1353C2.33552 44.8938 2.1665 39.3388 2.1665 32C2.1665 24.6612 2.33552 19.1062 3.02222 14.8647C3.70741 10.6327 4.87918 7.90865 6.72716 6.06066C8.57515 4.21267 11.2992 3.0409 15.5312 2.35572C19.7727 1.66901 25.3278 1.5 32.6665 1.5C40.0053 1.5 45.5604 1.66901 49.8018 2.35572C54.0338 3.0409 56.7579 4.21267 58.6058 6.06066C60.4538 7.90865 61.6256 10.6327 62.3108 14.8647C62.9975 19.1062 63.1665 24.6612 63.1665 32Z" fill="#17171D" stroke="#8492A6" stroke-width="3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.1196 22.6893C43.4497 23.2656 43.1882 23.9851 42.5847 24.262L42.4628 24.3176C41.9457 24.5554 41.3351 24.333 41.0499 23.8395C39.6311 21.3806 37.5851 19.5097 35.2315 18.3098C34.6481 18.0128 34.3547 17.3229 34.6055 16.7171C34.854 16.1148 35.5451 15.8237 36.1296 16.1124C38.9519 17.5075 41.4073 19.7251 43.0994 22.6562L43.1196 22.6893ZM40.1151 24.3791C40.3695 24.8181 40.1695 25.3696 39.7092 25.5814L39.0371 25.889C38.6809 26.0523 38.262 25.9021 38.0656 25.5624C37.042 23.7886 35.5877 22.4195 33.9109 21.5095C33.3346 21.196 33.04 20.5084 33.2908 19.9026C33.5393 19.3003 34.2304 19.008 34.809 19.3062C36.9544 20.4161 38.8182 22.132 40.1151 24.3791ZM43.9313 30.8448C43.7775 31.1856 43.6189 31.5382 43.5337 31.898C43.2024 33.2931 43.5337 35.0125 43.5337 35.0125C43.5337 35.0125 44.1408 38.3672 43.5337 40.0878C42.522 42.955 40.6097 45.1205 37.4834 45.1205C35.9663 45.1205 34.2115 43.6922 32.8021 42.5444L32.7998 42.5432C32.3406 42.1693 31.9194 41.8261 31.5537 41.5717C30.5988 40.9055 28.5883 39.747 27.1624 38.9246C26.3696 38.4678 25.7578 38.1152 25.6099 38.0099C24.8632 37.4797 24.7448 36.4644 25.1602 35.8006C25.5755 35.1367 26.6334 34.7238 27.4428 35.3628C28.2522 36.0018 30.9289 37.6596 30.9289 37.6596C30.9289 37.6596 26.6216 33.3073 25.8607 32.6648C25.0987 32.021 25.0004 30.8898 25.6099 30.2816C26.2181 29.6733 27.1766 29.6449 27.7387 30.1029C28.3007 30.5608 32.9027 35.0125 32.9027 35.0125C32.9027 35.0125 28.1848 29.0604 27.7387 28.5101C27.2937 27.9599 27.1671 26.9943 27.7387 26.4227C28.3114 25.85 29.3066 25.8476 29.8994 26.4227C30.4911 26.9978 35.08 32.5453 35.08 32.5453C35.08 32.5453 31.9217 27.2463 31.5537 26.4227C31.1869 25.5991 31.5182 24.5329 32.2838 24.2454C33.0506 23.959 33.9216 24.1815 34.4162 24.8726C34.5523 25.0607 34.9286 25.753 35.422 26.6606C36.7379 29.0793 38.8868 33.0292 39.508 33.0292C40.0725 33.0292 40.1127 32.7074 40.1908 32.0767C40.2311 31.7524 40.2819 31.3454 40.4192 30.8602C40.7825 29.5751 42.8722 27.4061 44.0532 28.7835C44.581 29.4 44.2674 30.0958 43.9313 30.8448ZM44.8804 45.7488C42.2534 48.6149 40.8902 48.1687 39.9944 47.5096C38.0774 46.1003 38.3306 46.0068 39.3944 45.6104C40.1612 45.3264 41.3469 44.8862 42.4462 43.6863C43.4438 42.5976 43.8982 41.5906 44.2047 40.9102C44.7064 39.7991 44.8153 39.5588 46.2566 41.2569C46.9772 42.1042 47.5085 42.8828 44.8804 45.7488ZM20.1914 35.4196C19.6317 35.5154 19.2554 36.0526 19.4021 36.6017C20.1441 39.3707 21.6516 41.7291 23.6361 43.5112C24.1201 43.9466 24.8644 43.8437 25.2608 43.3254C25.6584 42.8059 25.5531 42.0639 25.0726 41.6178C23.4905 40.1481 22.2894 38.23 21.6883 35.9899C21.5712 35.5521 21.1487 35.2574 20.7014 35.3332L20.1914 35.4196ZM16.9858 35.9639C17.6082 35.8586 18.1963 36.2716 18.3596 36.881C19.1655 39.8854 20.8079 42.4414 22.971 44.3643C23.4609 44.7998 23.5639 45.5417 23.1663 46.0612C22.7699 46.5795 22.0267 46.6825 21.5345 46.2541C18.9702 44.0212 17.0201 41.0262 16.0734 37.4939C16.0604 37.4454 16.0486 37.3969 16.0356 37.3472C15.8687 36.7058 16.3077 36.0799 16.9621 35.9686L16.9858 35.9639Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M62.5 32C62.5 39.3388 62.331 44.8938 61.6443 49.1353C60.9591 53.3673 59.7873 56.0914 57.9393 57.9393C56.0914 59.7873 53.3673 60.9591 49.1353 61.6443C44.8938 62.331 39.3388 62.5 32 62.5C24.6612 62.5 19.1062 62.331 14.8647 61.6443C10.6327 60.9591 7.90865 59.7873 6.06066 57.9393C4.21267 56.0914 3.0409 53.3673 2.35572 49.1353C1.66901 44.8938 1.5 39.3388 1.5 32C1.5 24.6612 1.66901 19.1062 2.35572 14.8647C3.0409 10.6327 4.21267 7.90865 6.06066 6.06066C7.90865 4.21267 10.6327 3.0409 14.8647 2.35572C19.1062 1.66901 24.6612 1.5 32 1.5C39.3388 1.5 44.8938 1.66901 49.1353 2.35572C53.3673 3.0409 56.0914 4.21267 57.9393 6.06066C59.7873 7.90865 60.9591 10.6327 61.6443 14.8647C62.331 19.1062 62.5 24.6612 62.5 32Z" fill="#17171D" stroke="#8492A6" stroke-width="3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.9875 32.3894C38.4527 31.0109 38.9301 28.782 38.9301 25.5714C38.9301 20.8376 35.1989 17 30.5964 17C25.9938 17 22.2627 20.8376 22.2627 25.5714C22.2627 28.782 22.74 31.0109 24.2052 32.3894C21.4967 33.9137 19.3588 36.3767 18.2054 39.3531C17.9343 40.0527 17.8489 40.9616 18.4735 41.3549C19.7935 42.1863 21.1943 40.5711 21.9822 39.2007C23.7228 36.1733 26.929 34.1429 30.5964 34.1429C30.7711 34.1429 30.9447 34.1475 31.1172 34.1566C33.003 34.256 35.0718 34.0619 36.5172 32.8123C36.6787 32.6728 36.8365 32.5316 36.9875 32.3894ZM35.5966 25.5714C35.5966 28.4559 35.1444 29.4133 34.8139 29.7816C34.5427 30.0836 33.6811 30.7143 30.5964 30.7143C27.5117 30.7143 26.6499 30.0836 26.3789 29.7816C26.0484 29.4133 25.5962 28.4559 25.5962 25.5714C25.5962 22.7311 27.8348 20.4286 30.5964 20.4286C33.3579 20.4286 35.5966 22.7311 35.5966 25.5714ZM38.9301 37.3657C41.0943 35.1714 43.2586 35.72 44.3406 36.8171C48.6691 41.2057 40.2575 47 38.9301 47C37.6025 47 29.1909 41.2057 33.5194 36.8171C34.6016 35.72 36.7658 35.1714 38.9301 37.3657Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB