Merge branch 'main' into garyhtou/security

This commit is contained in:
Gary Tou 2025-03-17 22:32:45 -07:00 committed by GitHub
commit 502b3324a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 929 additions and 406 deletions

View file

@ -72,7 +72,7 @@ _Have questions? Join us in [#hackclub-site-dev](https://hackclub.slack.com/arch
---
Hack Club, 2024. MIT License.
Hack Club, 2025. MIT License.
[next.js]: https://nextjs.org
[mdx]: https://mdxjs.com

View file

@ -0,0 +1,106 @@
import { useRouter } from 'next/router'
import { useRef, useState } from 'react'
import { Alert, Button, Text } from 'theme-ui'
import FormContainer from './form-container'
import OrganizationInfoForm from './org-form'
import PersonalInfoForm from './personal-form'
import { onSubmit } from './submit'
import TeenagerOrAdultForm from './teenager-or-adult-form'
import MultiStepForm from './multi-step-form'
export default function ApplicationForm() {
const router = useRouter()
const formContainer = useRef()
const [formError, setFormError] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const requiredFields = {
// Key: form field name
// Value: humanize field name used in error message
eventTeenagerLed: 'are you a teenager?',
eventName: 'project name',
eventPostalCode: 'project zip/postal code',
eventDescription: 'project description',
eventIsPolitical: "project's political activity",
eventPoliticalActivity: "project's political activity",
eventHasWebsite: 'project website',
eventAnnualBudget: 'project annual budget',
firstName: 'first name',
lastName: 'last name',
userEmail: 'email',
userPhone: 'phone number',
userBirthday: 'birthday',
userAddressLine1: 'address line 1',
userAddressCity: 'city',
userAddressProvince: 'state/province',
userAddressPostalCode: 'ZIP/postal code',
userAddressCountry: 'country',
referredBy: 'how did you hear about HCB?'
}
const submitButton = (
<Button
variant="ctaLg"
type="submit"
disabled={isSubmitting}
sx={{
backgroundImage: theme => theme.util.gx('cyan', 'blue'),
'&:disabled': {
background: 'muted',
cursor: 'not-allowed',
transform: 'none !important'
},
width: 'fit-content'
}}
>
{isSubmitting ? 'Submitting…' : 'Submit'}
</Button>
)
return (
<FormContainer
ref={formContainer}
className={formError ? 'has-errors' : null}
onSubmit={event =>
onSubmit({
event,
router,
form: formContainer,
setFormError,
setIsSubmitting,
requiredFields
})
}
>
<MultiStepForm
submitButton={submitButton}
validationErrors={
formError && (
<Alert bg="primary" sx={{ mt: 4 }}>
{formError}
</Alert>
)
}
>
{/* Step 1 */}
<MultiStepForm.Step title="Let's get started">
<Text as="p" variant="caption" sx={{ marginBottom: '1rem' }}>
Fill out this quick application to run your project on HCB. If you
are a teenager, there is a very high likelihood we will accept your
project. We just need to collect a few pieces of information first.
</Text>
<TeenagerOrAdultForm requiredFields={requiredFields} />
</MultiStepForm.Step>
{/* Step 2 */}
<MultiStepForm.Step>
<OrganizationInfoForm requiredFields={requiredFields} />
</MultiStepForm.Step>
{/* Step 3 */}
<MultiStepForm.Step title="Personal details">
<PersonalInfoForm requiredFields={requiredFields} />
</MultiStepForm.Step>
</MultiStepForm>
</FormContainer>
)
}

View file

@ -1,29 +0,0 @@
import { Box, Text } from 'theme-ui'
export default function Callout() {
return (
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'blue',
paddingLeft: 2,
color: 'blue',
fontSize: 1,
textWrap: 'pretty',
lineHeight: 1.375
}}
>
<Text as="p" sx={{ fontWeight: 'bold' }}>
HCB is a fiscal sponsor primarily for teenage-led organizations
</Text>
<Text as="p" sx={{ mt: 1, textWrap: 'balance' }}>
Although we would love to be able to support organizations across all
ages and missions, we are currently prioritizing applications from
teenagers.
</Text>
<Text as="p" sx={{ mt: 1, textWrap: 'balance' }}>
We are accepting adult-led organizations on a case-by-case basis.
</Text>
</Box>
)
}

View file

@ -28,7 +28,6 @@ export default function Checkbox({ name, defaultChecked = false, size = 38 }) {
name={name}
aria-checked={checked}
role="checkbox"
tabindex="0"
onClick={() => toggle()}
onKeyDown={e => e.key === 'Enter' && toggle()}
/>

View file

@ -11,17 +11,24 @@ export default function Field({
children
}) {
const router = useRouter()
const isRequired = requiredFields.includes(name)
const isRequired = Object.keys(requiredFields).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 =
router.query[name] || sessionStorage.getItem('bank-signup-' + name)
if (value) {
const input = document.getElementById(name)
if (input) input.value = value
if (!value) return
let input = document.getElementById(name)
if (input) {
input.value = value
return
}
// Maybe it's radio buttons
input = document.querySelector(`input[name='${name}']`)
if (input) input.checked = true
}, [router.query, name])
return (

View file

@ -1,5 +1,7 @@
import { forwardRef } from 'react'
import { Box, Container } from 'theme-ui'
import { TeenagerLedProvider } from '../../../components/fiscal-sponsorship/apply/teenager-led-context'
import { MultiStepProvider } from './multi-step-context'
const formContainer = forwardRef(({ children, ...props }, ref) => {
return (
@ -9,11 +11,15 @@ const formContainer = forwardRef(({ children, ...props }, ref) => {
sx={{
bg: 'snow',
px: [3, 5],
py: 5,
minHeight: '100dvb',
py: [1, 5],
pb: 5,
minHeight: [null, null, '100dvb'],
'&.has-errors div[aria-required="true"] input:placeholder-shown': {
borderColor: 'primary'
},
'&.has-errors div[aria-required="true"] input[type="date"]': {
borderColor: 'primary'
},
'&.has-errors div[aria-required="true"] textarea:placeholder-shown': {
borderColor: 'primary'
}
@ -24,14 +30,10 @@ const formContainer = forwardRef(({ children, ...props }, ref) => {
variant="copy"
sx={{
ml: 0,
display: 'flex',
flexDirection: 'column',
columnGap: 4,
rowGap: 3,
px: 0
}}
>
{children}
<TeenagerLedProvider>{children}</TeenagerLedProvider>
</Container>
</Box>
)

View file

@ -1,19 +1,25 @@
import { Box, Link, Heading } from 'theme-ui'
import { Box, Link, Heading, Grid } from 'theme-ui'
import Icon from '../../icon'
import { useMultiStepContext } from './multi-step-context'
import { useEffect } from 'react'
export default function HCBInfo() {
const { step } = useMultiStepContext()
const firstStep = step === 0
return (
<Box
sx={{
display: firstStep ? 'block' : ['none', 'block'],
gridArea: 'info',
alignItems: 'start',
mark: { color: '#ec555c', bg: 'inherit' },
ul: { pl: [3, 0], color: 'muted', mb: 4 },
ul: { pl: 3, color: 'muted', mb: 4 },
p: { color: 'muted', mb: 0 }
}}
>
<Heading variant="subheadline">
HCB is a{' '}
HCB is not a bank, we're a{' '}
<Link
href="https://en.wikipedia.org/wiki/Fiscal_sponsorship"
target="_blank"
@ -27,39 +33,22 @@ export default function HCBInfo() {
<Icon glyph="external" size={24} aria-hidden />
</Link>
</Heading>
{/* <Grid columns={2} bg="white" sx={{ color: 'muted', border: 'slate 1px' }}>
<Box>Gives your project nonprofit status.</Box>
</Grid> */}
<ul>
<li>Nonprofit status.</li>
<li>Tax-deductible donations.</li>
<li>HCB gives your project nonprofit status.</li>
<li>Allows you to raise tax-deductible donations.</li>
<li>Provides a financial platform.</li>
<li>Allows you to order cards to make purchases.</li>
</ul>
<Heading variant="subheadline">
HCB provides a financial platform.
</Heading>
<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>
<Heading variant="subheadline">HCB is not a bank.</Heading>
<ul>
<li>
We partner with{' '}
<Link href="https://column.com" target="_blank">
Column N.A.
</Link>{' '}
to offer restricted funds to fiscally-sponsored projects.
</li>
<li>
You can't deposit or withdraw cash. But you can receive any kind of
electronic payment!
</li>
</ul>
<Heading variant="subheadline">HCB is not for for-profits.</Heading>
<p>
If youre looking to set up a for-profit entity, consider{' '}
<Link href="https://stripe.com/atlas" target="_blank">
Stripe Atlas
</Link>
.
HCB partners with{' '}
<Link href="https://column.com" target="_blank">
Column N.A.
</Link>{' '}
to provide restricted funds to fiscally-sponsored projects.
</p>
</Box>
)

View file

@ -0,0 +1,37 @@
import { createContext, useContext, useState } from 'react'
const MultiStepContext = createContext()
const useMultiStepContext = () => useContext(MultiStepContext)
const MultiStepProvider = ({ children }) => {
const [step, setStep] = useState(0)
const useStepper = steps => {
const modifyStep = number => {
const newStep = step + number
// Guard against invalid step numbers
if (newStep < 0 || newStep > steps.length - 1) {
console.error(
`[MultiStepProvider] Invalid new step number: ${newStep}. Current step number: ${step}`
)
return
}
setStep(newStep)
}
return {
nextStep: () => modifyStep(1),
previousStep: () => modifyStep(-1)
}
}
return (
<MultiStepContext.Provider value={{ step, useStepper }}>
{children}
</MultiStepContext.Provider>
)
}
export { MultiStepProvider, useMultiStepContext }

View file

@ -0,0 +1,74 @@
import { Box, Button, Heading } from 'theme-ui'
import { useMultiStepContext } from './multi-step-context'
import { Children } from 'react'
export default function MultiStepForm({
children,
submitButton,
validationErrors
}) {
const { step, useStepper } = useMultiStepContext()
const steps = Children.toArray(children)
const { nextStep, previousStep } = useStepper(steps)
return (
<>
{/*
We must render all form fields to DOM so that they can be submitted
with the form. So, we simple hide all non-current steps.
*/}
{steps.map((stepComponent, index) => (
<Box
key={index}
sx={{
display: 'flex',
flexDirection: 'column',
rowGap: 3,
...(step !== index ? { display: 'none' } : {})
}}
>
{stepComponent}
</Box>
))}
{validationErrors}
<Box
sx={{
display: 'flex',
gap: '1rem',
marginTop: '2rem',
justifyContent: 'flex-end'
}}
>
{step > 0 && (
<Button type="button" variant="outline" onClick={previousStep}>
Back
</Button>
)}
{step < steps.length - 1 && (
<Button type="button" variant="primary" onClick={nextStep}>
Continue
</Button>
)}
{step === steps.length - 1 && submitButton}
</Box>
</>
)
}
function Step({ children, title }) {
return (
<>
{title && (
<Heading as="h2" variant="headline">
{title}
</Heading>
)}
{children}
</>
)
}
MultiStepForm.Step = Step

View file

@ -1,77 +1,16 @@
import { useEffect, useState } from 'react'
import { Input, Select, Textarea } from 'theme-ui'
import { Input, Textarea } from 'theme-ui'
import Field from './field'
import useOrganizationI18n from '../organizationI18n'
import { useTeenagerLedContext } from './teenager-led-context'
export default function OrganizationAdultForm({ requiredFields }) {
const org = useOrganizationI18n()
const { teenagerLed, setTeenagerLed } = useTeenagerLedContext()
const onTeenagerLedChange = e => {
const newValue = e.target.value
setTeenagerLed(newValue)
if (newValue === 'true') {
// Clear cache of removed fields
sessionStorage.removeItem('bank-signup-eventPoliticalActivity')
sessionStorage.removeItem('bank-signup-eventAnnualBudget')
}
}
useEffect(() => {
// [@garyhtou] welp... this exists because the Field component will cache
// input values and set them on page load. It does it by directly setting
// `input.value` with JavaScript; bypassing React. Because of that, the
// `teenagerLed` state may not be synced with the DOM input value. This code
// syncs `teenagerLed` with the DOM input value.
// NOTE: This depends on Field's useEffect hook to run first.
const eventTeenagerLedElm = document.getElementById('eventTeenagerLed')
setTeenagerLed(eventTeenagerLedElm.value)
}, [])
const { teenagerLed } = useTeenagerLedContext()
return (
<>
<Field
name="eventTeenagerLed"
label={`Is your ${org.toLowerCase()} led by teenagers?`}
col={true}
description={`This means your ${org.toLowerCase()} was founded and is being led exclusively by teenagers.`}
requiredFields={requiredFields}
>
<Select
name="eventTeenagerLed"
id="eventTeenagerLed"
onChange={onTeenagerLedChange}
value={teenagerLed}
>
{Object.entries({ Yes: 'true', No: 'false' }).map(([name, value]) => (
<option key={name} value={value}>
{name}
</option>
))}
</Select>
</Field>
{teenagerLed !== 'true' && (
<>
<Field
name="eventPoliticalActivity"
label={`Please describe any political activity your ${org.toLowerCase()} is involved in, if any`}
description="This includes but is not limited to protests, public demonstrations, political education, and lobbying."
requiredFields={requiredFields}
>
<Textarea
name="eventPoliticalActivity"
id="eventPoliticalActivity"
placeholder="We are involved in..."
rows={3}
sx={{
resize: 'vertical'
}}
/>
</Field>
<Field
name="eventAnnualBudget"
label="What is your estimated annual budget (USD) for this year?"

View file

@ -1,15 +1,51 @@
import { useState, useEffect } from 'react'
import { Input, Select, Textarea } from 'theme-ui'
import { Input, Link, Select, Text, Textarea } from 'theme-ui'
import Checkbox from './checkbox'
import Field from './field'
// This is using country-list instead of country-list-js as it has a smaller bundle size
import { getNames } from 'country-list'
import useOrganizationI18n from '../organizationI18n'
import OrganizationAdultForm from './org-adult-form'
import { useTeenagerLedContext } from './teenager-led-context'
export default function OrganizationInfoForm({ requiredFields }) {
const org = useOrganizationI18n()
const { teenagerLed } = useTeenagerLedContext()
const [hasWebsite, setHasWebsite] = useState(true)
const onHasWebsiteChange = e => {
const newValue = e.target.value === 'true'
setHasWebsite(newValue)
}
const [isPolitical, setIsPolitical] = useState(null)
const onIsPoliticalChange = e => {
const newValue = e.target.value === 'true'
setIsPolitical(newValue)
}
const politicalActivityTextarea = admittedToActivity => (
<Field
name="eventPoliticalActivity"
label={`Please describe ${admittedToActivity ? 'the' : 'any'} political activity your ${org.toLowerCase()} is involved in${admittedToActivity ? '.' : ', if any.'}`}
requiredFields={requiredFields}
>
<Textarea
name="eventPoliticalActivity"
id="eventPoliticalActivity"
placeholder="We are involved in..."
rows={3}
sx={{
resize: 'vertical'
}}
/>
</Field>
)
const noPoliticalActivity = (
<input type="hidden" name="eventPoliticalActivity" value="None." />
)
return (
<>
<Field
@ -23,48 +59,108 @@ export default function OrganizationInfoForm({ requiredFields }) {
placeholder="Shelburne School Hackathon"
/>
</Field>
<Field
name="eventWebsite"
label={`${org} website`}
description="If you dont have one yet, you can leave this blank."
name="eventDescription"
label={`Tell us about your ${org.toLowerCase()}`}
requiredFields={requiredFields}
>
<Input
name="eventWebsite"
id="eventWebsite"
inputMode="url"
placeholder="hackclub.com"
<Text variant="caption">
Are you running a hackathon, robotics team, organizing a nonprofit,
building a project, etc.?
</Text>
<Textarea
name="eventDescription"
id="eventDescription"
rows={3}
sx={{
resize: 'vertical'
}}
/>
</Field>
<Field
name="eventLocation"
label={`Primary country of operations`}
label="Does your project have a website?"
name="eventHasWebsite"
requiredFields={requiredFields}
>
<Select name="eventLocation" id="eventLocation">
{getNames()
.sort()
.sort(item => (item === 'United States of America' ? -1 : 1))
.map(country => (
<option key={country} value={country}>
{country}
</option>
))}
<Select
onChange={onHasWebsiteChange}
value={hasWebsite ? 'true' : 'false'}
>
{Object.entries({ Yes: 'true', No: 'false' }).map(([name, value]) => (
<option key={name} value={value}>
{name}
</option>
))}
</Select>
</Field>
<Field
name="eventPostalCode"
label={`ZIP code / postal code`}
description="If your organization runs online, please put your own postal code."
requiredFields={requiredFields}
>
<Input
name="eventPostalCode"
id="eventPostalCode"
placeholder="90069"
/>
</Field>
<Field
{hasWebsite ? (
<Field
name="eventWebsite"
label={`${org} website`}
requiredFields={requiredFields}
>
<Input
name="eventWebsite"
id="eventWebsite"
inputMode="url"
placeholder="hackclub.com"
/>
</Field>
) : (
<>
{/* No website */}
<input type="hidden" name="eventWebsite" value="" />
{teenagerLed === 'true' && (
/* don't show Boba Drops to adult-led orgs lol*/
<Text variant="caption">
A website is not required to apply for HCB. However, most
successful projects that raise money have a custom-build website.
If you've never built a website before, checkout{' '}
<Link href="https://boba.hackclub.com/">Boba Drops</Link>, a Hack
Club workshop on how to build a website.
</Text>
)}
</>
)}
{teenagerLed === 'true' ? (
<>
<Field
name="eventIsPolitical"
label="Is your project involved with political activity?"
requiredFields={requiredFields}
>
<Select
name="eventIsPolitical"
onChange={onIsPoliticalChange}
value={isPolitical ? 'true' : 'false'}
>
{Object.entries({ Yes: 'true', No: 'false' }).map(
([name, value]) => (
<option key={name} value={value}>
{name}
</option>
)
)}
</Select>
</Field>
{isPolitical ? politicalActivityTextarea(true) : noPoliticalActivity}
</>
) : (
// Adults always get the text area
politicalActivityTextarea(false)
)}
<Text variant="caption">
This includes but is not limited to protests, public demonstrations,
political education, and lobbying.
</Text>
{/* Move transparency mode prompt to HCB onboarding */}
{/* <Field
name="transparent"
label="Transparency mode"
col={true}
@ -77,22 +173,7 @@ export default function OrganizationInfoForm({ requiredFields }) {
requiredFields={requiredFields}
>
<Checkbox defaultChecked={true} name="transparent" />
</Field>
<Field
name="eventDescription"
label={`Tell us about your ${org.toLowerCase()}`}
description="24 sentences will suffice."
requiredFields={requiredFields}
>
<Textarea
name="eventDescription"
id="eventDescription"
rows={3}
sx={{
resize: 'vertical'
}}
/>
</Field>
</Field> */}
<OrganizationAdultForm requiredFields={requiredFields} />
</>

View file

@ -1,8 +1,9 @@
import { Input, Flex, Label, Radio, Grid, Select } from 'theme-ui'
import { Input, Flex, Label, Radio, Grid, Select, Box, Text } from 'theme-ui'
import Field from './field'
import Checkbox from './checkbox'
import { useEffect, useState } from 'react'
import { useTeenagerLedContext } from './teenager-led-context'
import { getNames } from 'country-list'
export default function PersonalInfoForm({ requiredFields }) {
const [selectedContactOption, setSelectedContactOption] = useState('Email')
@ -41,9 +42,9 @@ export default function PersonalInfoForm({ requiredFields }) {
label="Preferred contact channel"
requiredFields={requiredFields}
>
<Grid
columns={[null, 2]}
<Flex
sx={{
flexDirection: ['column', 'row'],
rowGap: 2,
columnGap: 4,
width: '100%'
@ -52,7 +53,8 @@ export default function PersonalInfoForm({ requiredFields }) {
<Label
sx={{
display: 'flex',
flexDirection: 'row'
flexDirection: 'row',
width: 'fit-content'
}}
>
<Radio
@ -67,7 +69,8 @@ export default function PersonalInfoForm({ requiredFields }) {
sx={{
columnGap: 0,
rowGap: 2,
gridTemplateColumns: 'auto 1fr'
gridTemplateColumns: 'auto 1fr',
flexGrow: 1
}}
>
<Label
@ -88,7 +91,6 @@ export default function PersonalInfoForm({ requiredFields }) {
<div />
<Field
label="Your Hack Club Slack username"
description="For teenagers only!"
name="slackUsername"
requiredFields={requiredFields}
>
@ -104,7 +106,7 @@ export default function PersonalInfoForm({ requiredFields }) {
</>
) : null}
</Grid>
</Grid>
</Flex>
</Field>
) : (
// When not teenage-led, default to "email" as preferred contact channel
@ -126,37 +128,90 @@ export default function PersonalInfoForm({ requiredFields }) {
</Field>
<Field
name="userBirthday"
label="Birth year"
label="Birthday"
requiredFields={requiredFields}
>
<Select name="userBirthday" id="userBirthday" defaultValue="">
<option value="" disabled>
Select a year
</option>
{/* show a century of years starting from 13 years ago */}
{Array.from({ length: 98 }, (_, i) => {
const year = new Date().getFullYear() - 13 - i
return (
<option key={year} value={year}>
{year}
</option>
)
})}
</Select>
<Input type="date" name="userBirthday" id="userBirthday" />
</Field>
{/* <Field
<Flex sx={{ flexDirection: 'column', gap: 1 }}>
<Field
name="userAddressLine1"
label={'Your personal address'}
requiredFields={requiredFields}
>
<Input
name="userAddressLine1"
id="userAddressLine1"
placeholder="8605 Santa Monica Blvd, Suite 86294"
/>
</Field>
<Grid columns={2} gap={1}>
<Field
name="userAddressCity"
label={<Text sx={{ fontSize: 1 }}>City</Text>}
requiredFields={requiredFields}
>
<Input
name="userAddressCity"
placeholder="Santa Monica"
id="userAddressCity"
/>
</Field>
<Field
name="userAddressProvince"
label={<Text sx={{ fontSize: 1 }}>State / Province</Text>}
requiredFields={requiredFields}
>
<Input
name="userAddressProvince"
placeholder="California"
id="userAddressProvince"
/>
</Field>
<Field
name="userAddressPostalCode"
label={<Text sx={{ fontSize: 1 }}>ZIP / Postal code</Text>}
requiredFields={requiredFields}
>
<Input
name="userAddressPostalCode"
placeholder="90069"
id="userAddressPostalCode"
/>
</Field>
<Field
name="userAddressCountry"
label={<Text sx={{ fontSize: 1 }}>Country</Text>}
requiredFields={requiredFields}
>
<Select name="userAddressCountry" id="userAddressCountry">
{getNames()
.sort()
.sort(item => (item === 'United States of America' ? -1 : 1))
.map(country => (
<option key={country} value={country}>
{country}
</option>
))}
</Select>
</Field>
</Grid>
</Flex>
<Field
name="referredBy"
label="Who were you referred by?"
label="How did you hear about HCB?"
requiredFields={requiredFields}
>
<Input
name="referredBy"
id="referredBy"
placeholder="Max"
placeholder="Word of mouth, an event, etc. Be specific!"
/>
</Field>
*/}
<Field
name="returningUser"
label="Have you used HCB before?"
@ -165,19 +220,7 @@ export default function PersonalInfoForm({ 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>
*/}
<Field
name="accommodations"
label="Accessibility needs"

View file

@ -1,3 +1,5 @@
import _ from 'lodash'
async function sendApplication() {
// Get the form data from sessionStorage
const data = {}
@ -7,7 +9,7 @@ async function sendApplication() {
data[key.replace('bank-signup-', '')] = sessionStorage.getItem(key)
}
}
console.dir('Sending data:', data)
console.log({ data })
// Send the data
try {
@ -22,6 +24,8 @@ async function sendApplication() {
}
}
const isBlank = string => !string || string.trim() === ''
export function onSubmit({
event,
router,
@ -32,28 +36,34 @@ export function onSubmit({
setIsSubmitting
}) {
event.preventDefault()
/* Don't return from inside the loop since
we want all input values to be saved every time */
let wasError = false
const formData = new FormData(form.current)
const missingFields = []
const conditionalRequiredFields = _.cloneDeep(requiredFields) // Deep clone to prevent modification from leaking
if (formData.get('contactOption') === 'Slack') {
// If contact option is Slack, they must provide a Slack username
conditionalRequiredFields.slackUsername = 'slack username'
}
// Save form data
formData.forEach((value, key) => {
// Save form data
sessionStorage.setItem('bank-signup-' + key, value)
// Check if there are empty required fields.
if (
((!value || value.trim() === '') && requiredFields.includes(key)) ||
(formData.get('contactOption') === 'slack' &&
(!formData.get('slackUsername') != null ||
formData.get('slackUsername') === '')) // I'm so sorry for this
isBlank(value) &&
Object.keys(conditionalRequiredFields).includes(key)
) {
setFormError('Please fill out all required fields.')
wasError = true
missingFields.push(conditionalRequiredFields[key])
}
})
if (wasError) return
if (missingFields.length !== 0) {
setFormError(
`Please fill out all required fields: ${missingFields.join(', ')}`
)
return // Don't submit application
}
if (!formError) {
setIsSubmitting(true)
@ -61,7 +71,7 @@ export function onSubmit({
const isAdult = formData.get('eventTeenagerLed') !== 'true'
const acceptanceEta = isAdult
? 'within two weeks'
: 'within two business days'
: 'within 24 hours on weekdays and 48 hours on weekends'
router.push(`/fiscal-sponsorship/apply/success?eta=${acceptanceEta}`)
})

View file

@ -4,7 +4,7 @@ const TeenagerLedContext = createContext()
const useTeenagerLedContext = () => useContext(TeenagerLedContext)
const TeenagerLedProvider = ({ children }) => {
const [teenagerLed, setTeenagerLed] = useState('false')
const [teenagerLed, setTeenagerLed] = useState('true')
return (
<TeenagerLedContext.Provider value={{ teenagerLed, setTeenagerLed }}>

View file

@ -0,0 +1,95 @@
import { useEffect } from 'react'
import { Flex, Input, Label, Radio, Select } from 'theme-ui'
import Field from './field'
import { useTeenagerLedContext } from './teenager-led-context'
export default function TeenagerOrAdultForm({ requiredFields }) {
const { teenagerLed, setTeenagerLed } = useTeenagerLedContext()
const onTeenagerLedChange = e => {
const newValue = e.target.value
setTeenagerLed(newValue)
if (newValue === 'true') {
// Clear cache of removed fields
sessionStorage.removeItem('bank-signup-eventPoliticalActivity')
sessionStorage.removeItem('bank-signup-eventAnnualBudget')
}
}
useEffect(() => {
// [@garyhtou] welp... this exists because the Field component will cache
// input values and set them on page load. It does it by directly setting
// `input.value` with JavaScript; bypassing React. Because of that, the
// `teenagerLed` state may not be synced with the DOM input value. This code
// syncs `teenagerLed` with the DOM input value.
// NOTE: This depends on Field's useEffect hook to run first.
const eventTeenagerLedElm = document.querySelector(
'input[name="eventTeenagerLed"]:checked'
)
if (eventTeenagerLedElm) setTeenagerLed(eventTeenagerLedElm.value)
})
return (
<Field
name="eventTeenagerLed"
label={'Are you a teenager?'}
col={true}
description={'18 and under'}
requiredFields={requiredFields}
>
<Flex columns={2} sx={{ gap: 3 }}>
<Label
sx={{
alignItems: 'center',
backgroundColor: ' rgba(91,192,222,.25)',
borderRadius: '.75rem',
color: '#338eda',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
lineHeight: '1.25',
padding: '1rem',
textAlign: 'center',
boxShadow:
teenagerLed === 'true' ? '0 0 0 1px #fff,0 0 0 3px #338eda' : null
}}
>
<Radio
name="eventTeenagerLed"
value="true"
onChange={onTeenagerLedChange}
sx={{ display: 'none!important' }}
/>
Yes, I'm a teenager
</Label>
<Label
sx={{
alignItems: 'center',
backgroundColor: ' rgba(91,192,222,.25)',
borderRadius: '.75rem',
color: '#338eda',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
lineHeight: '1.25',
padding: '1rem',
textAlign: 'center',
boxShadow:
teenagerLed === 'false'
? '0 0 0 1px #fff,0 0 0 3px #338eda'
: null
}}
>
<Radio
name="eventTeenagerLed"
value="false"
onChange={onTeenagerLedChange}
sx={{ display: 'none!important' }}
/>
I'm an adult
</Label>
</Flex>
</Field>
)
}

View file

@ -1,14 +1,19 @@
import Icon from '../icon'
import { Flex, Link, Text } from 'theme-ui'
import { useMultiStepContext } from './apply/multi-step-context'
const phoneNumber = '+1 (844) 237-2290'
const phoneNumberUri = '+1-844-237-2290'
const email = 'hcb@hackclub.com'
export default function ContactBanner({ sx }) {
const stepContext = useMultiStepContext()
const firstStep = stepContext?.step === 0
return (
<Flex
sx={{
display: firstStep ? 'flex' : ['none', 'flex'],
bg: 'sunken',
color: 'slate',
alignItems: 'center',

View file

@ -43,9 +43,6 @@ function formatMoney(amount) {
}
const Stats = ({ stats }) => {
if (stats.transactions_volume === undefined) {
return null
}
const [balance, setBalance] = useState(0) // A formatted balance string, split by decimal
useEffect(() => {
@ -67,6 +64,9 @@ const Stats = ({ stats }) => {
return () => observer.disconnect()
}, [stats.transactions_volume])
if (stats.transactions_volume === undefined) return null
return (
<Box id="parent">
<Flex sx={{ flexDirection: 'column', alignItems: 'center' }}>

View file

@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'
export default function useOrganizationI18n() {
const [org, setOrg] = useState('Organization')
const [org, setOrg] = useState('Project')
useEffect(() => {
if (navigator.language === 'en-GB') setOrg('Organisation')
if (navigator.language === 'en-GB') setOrg('Project')
}, [])
return org

View file

@ -0,0 +1,182 @@
import CardModel from './card-model'
import { Box, Flex, Grid, Image, Text, Heading } from 'theme-ui'
import Buttons from './button'
import { Global } from '@emotion/react'
/** @jsxImportSource theme-ui */
export default function Scrapyard() {
return (
<CardModel
color="white"
sx={{
backgroundSize: 'cover',
backgroundColor: '#90A8E1',
fontFamily: "p22stanyan",
objectFit: "contain"
}}
position={[null, 'bottom', 'bottom']}
>
<Global
styles={`
@font-face {
font-family: 'p22stanyan';
src: url('https://use.typekit.net/af/444506/00000000000000007735b3cd/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n7&v=3') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'moonblossom';
src: url('https://use.typekit.net/af/bf03be/00000000000000007735fbe5/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3') format('woff2');
font-weight: normal;
font-style: normal;
}
`}
/>
<Grid columns={[1, 1, 1]} sx={{ position: 'relative', zIndex: 2 }}>
<Flex
sx={{
flexDirection: 'column',
justifyContent: 'space-between',
position: 'relative',
alignItems: 'center',
}}
>
<Image
src="https://cloud-4fnsp2wse-hack-club-bot.vercel.app/0scrapyard.png"
sx={{
width: ['400px', '450px', '500px'],
mt: ['30px', '40px', '45px'],
mb: ['30px', '30px', '30px'],
position: 'relative',
zIndex: 3,
ml: '0px',
mr: '0px',
fontSize: ['48px', 4, 5],
color: 'white',
// mx: 'auto'
}}
alt="Scrapyard"
/>
<Flex
sx={{
flexDirection: ['row', 'row', 'column'],
justifyContent: 'space-between'
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box
sx={{
background: "url('https://scrapyard.hackclub.com/elements/ripped-paper.png')",
backgroundSize: 'cover',
display: 'block',
width: 'min(500px, calc(100vw - 30px))',
filter: 'drop-shadow(5px 5px 5px #000000AA)',
position: 'relative',
zIndex: 20
}}
>
<Heading
as="h2"
sx={{
fontFamily: 'moonblossom',
textAlign: 'center',
margin: '8%',
fontSize: '22px',
color: '#1f2d3d'
}}
>
Build stupid stuff, get stupid prizes.
</Heading>
</Box>
<Box
sx={{
zIndex: 1,
width: 'max-content',
backgroundImage: "url('https://scrapyard.hackclub.com/elements/yellow-strip@stretch.svg')",
backgroundRepeat: 'no-repeat',
backgroundSize: '100% 100%',
width: '75%',
position: 'relative',
zIndex: 30,
top: '-15%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
// pt: '3%',
filter: 'drop-shadow(5px 5px 5px #00000099)'
}}
>
<Heading
as="h2"
sx={{
fontFamily: 'p22stanyan',
mx: '8%',
my: '3%',
p: 0,
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
width: 'min-content',
fontSize: ['1.2em', '1.4em'],
color: '#1f2d3d'
}}
>
100+&nbsp;Cities&nbsp;worldwide&nbsp;&nbsp;March&nbsp;15-16
</Heading>
</Box>
</Box>
</Flex>
</Flex>
<Buttons
href="https://scrapyard.hackclub.com/"
target="_blank"
rel="noopener"
primary="#fde778"
icon = "door-enter"
id="43"
zIndex={999}
sx = {{zIndex: 9999999, left: ["50%", "50%", "0%"], color: '#1f2d3d'}}
>
Learn More
</Buttons>
</Grid>
<Image
src="https://cloud-hqnbfdg3v-hack-club-bot.vercel.app/0image__14_.png"
sx={{
width: ['100%', '100%', '100%'],
mb: ['0px', '0px', '0px'],
mr: ['0px', '0px', '0px'],
ml: ['0px', '0px', '0px'],
position: 'absolute',
zIndex: 1,
left: 0,
bottom: 0,
fontSize: ['36px', 4, 5],
color: 'white',
objectFit: 'cover',
height: '100%',
mx: 0
}}
alt=""
/>
</CardModel>
)
}

View file

@ -19,9 +19,9 @@ export default async function handler(req, res) {
body: JSON.stringify({
email: data.userEmail,
name: data.eventName,
country: getCode(data.eventLocation) || '',
postal_code: data.eventPostalCode || '',
transparent: data.transparent
country: getCode(data.userAddressCountry) || '',
postal_code: data.userAddressPostalCode || '',
transparent: 'false'
}),
method: 'POST',
headers: {
@ -39,16 +39,13 @@ export default async function handler(req, res) {
'Date of Birth': data.userBirthday,
'Event Name': data.eventName,
'Event Website': data.eventWebsite,
'Zip Code': data.eventPostalCode,
'Zip Code': data.userAddressPostalCode,
'Tell us about your event': data.eventDescription,
'Mailing Address': data.userAddress,
'Address Line 1': data.addressLine1,
City: data.addressCity,
State: data.addressState,
'Address Country': data.addressCountry,
'Address Country Code': data.addressCountryCode,
'Event Location': data.eventLocation,
'Event Country Code': data.eventCountryCode,
'Address Line 1': data.userAddressLine1,
City: data.userAddressCity,
State: data.userAddressProvince,
'Address Country': data.userAddressCountry,
'Event Location': data.userAddressCountry,
'Have you used HCB for any previous events?':
data.returningUser === 'true'
? 'Yes, I have used HCB before'
@ -65,7 +62,7 @@ export default async function handler(req, res) {
'(Adults) Annual Budget': parseInt(data.eventAnnualBudget),
'HCB ID': r.id
})
res.status(200).end();
res.status(200).end()
})
.catch(error => {
console.error(error)

View file

@ -1,162 +1,105 @@
import { useRouter } from 'next/router'
import { useRef, useState } from 'react'
import { Alert, Box, Button, Flex, Grid, Heading, Text } from 'theme-ui'
import { Box, Flex, Grid, Heading, Text } from 'theme-ui'
import Head from 'next/head'
import Link from 'next/link'
import Icon from '@hackclub/icons'
import Meta from '@hackclub/meta'
import ForceTheme from '../../../components/force-theme'
import FormContainer from '../../../components/fiscal-sponsorship/apply/form-container'
import HCBInfo from '../../../components/fiscal-sponsorship/apply/hcb-info'
import OrganizationInfoForm from '../../../components/fiscal-sponsorship/apply/org-form'
import PersonalInfoForm from '../../../components/fiscal-sponsorship/apply/personal-form'
import { onSubmit } from '../../../components/fiscal-sponsorship/apply/submit'
import Watermark from '../../../components/fiscal-sponsorship/apply/watermark'
import ContactBanner from '../../../components/fiscal-sponsorship/contact'
import Callout from '../../../components/fiscal-sponsorship/apply/callout'
import { TeenagerLedProvider } from '../../../components/fiscal-sponsorship/apply/teenager-led-context'
import ApplicationForm from '../../../components/fiscal-sponsorship/apply/application-form'
import { MultiStepProvider } from '../../../components/fiscal-sponsorship/apply/multi-step-context'
export default function Apply() {
const router = useRouter()
const formContainer = useRef()
const [formError, setFormError] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const requiredFields = [
'eventName',
'eventLocation',
'eventPostalCode',
'eventDescription',
'eventTeenagerLed',
'eventPoliticalActivity',
'eventAnnualBudget',
'firstName',
'lastName',
'userEmail',
'userPhone',
'userBirthday',
'slackUsername'
]
return (
<>
<Meta as={Head} title="Apply for HCB" />
<ForceTheme theme="light" />
<Grid
columns={[null, null, 2]}
sx={{
gap: 0,
width: '100%',
minHeight: '100vh',
alignItems: 'start'
}}
>
<Flex
<MultiStepProvider>
<Grid
columns={[null, null, 2]}
sx={{
flexDirection: 'column',
px: [3, 5],
py: 4,
gap: 4,
height: [null, '100svh'],
position: [null, null, 'sticky'],
top: 0,
overflowY: [null, null, 'auto']
gap: 0,
width: '100%',
minHeight: '100vh',
alignItems: 'start'
}}
>
{/* vertically align h1 to top of form */}
<Box as="header" sx={{ mt: [null, 3], mb: 'auto' }}>
<Link href="/fiscal-sponsorship" passHref legacyBehavior>
<Text
as="a"
variant="subheadline"
sx={{
mb: 3,
gap: 2,
display: 'flex',
alignItems: 'center',
color: 'muted',
textDecoration: 'none',
':hover': { color: 'primary' }
}}
>
<Icon
size={24}
glyph="inserter"
style={{ transform: 'rotate(180deg)' }}
/>
Back
</Text>
</Link>
<Heading as="h1" variant="title">
Apply to join
<br />
<Flex sx={{ alignItems: 'center', gap: 3 }}>
<img
src="/fiscal-sponsorship/hcb-icon-small.png"
width={48}
height={48}
alt="HCB logo"
style={{
width: '1em',
height: '1em',
verticalAlign: 'baseline'
<Flex
sx={{
flexDirection: 'column',
px: [3, 5],
py: 4,
gap: 4,
height: [null, null, '100svh'],
position: [null, null, 'sticky'],
top: 0,
overflowY: [null, null, 'auto']
}}
>
{/* vertically align h1 to top of form */}
<Box as="header" sx={{ mt: [null, 3], mb: 'auto' }}>
<Link href="/fiscal-sponsorship" passHref legacyBehavior>
<Text
as="a"
variant="subheadline"
sx={{
mb: 3,
gap: 2,
display: 'flex',
alignItems: 'center',
color: 'muted',
textDecoration: 'none',
':hover': { color: 'primary' }
}}
/>{' '}
HCB
</Flex>
</Heading>
</Box>
<HCBInfo />
<ContactBanner
sx={{ borderRadius: 'default', bg: 'snow', width: 'fit-content' }}
/>
</Flex>
<FormContainer
ref={formContainer}
className={formError ? 'has-errors' : null}
onSubmit={event =>
onSubmit({
event,
router,
form: formContainer,
setFormError,
setIsSubmitting,
requiredFields
})
}
>
<TeenagerLedProvider>
<Callout />
<Heading as="h2" variant="headline" sx={{ mb: -2 }}>
Your organization
</Heading>
<OrganizationInfoForm requiredFields={requiredFields} />
<Heading as="h2" variant="headline" sx={{ mb: -2 }}>
Personal details
</Heading>
<PersonalInfoForm requiredFields={requiredFields} />
{formError && <Alert bg="primary">{formError}</Alert>}
<Button
variant="ctaLg"
type="submit"
disabled={isSubmitting}
sx={{
backgroundImage: theme => theme.util.gx('cyan', 'blue'),
'&:disabled': {
background: 'muted',
cursor: 'not-allowed',
transform: 'none !important'
},
width: 'fit-content'
}}
>
{isSubmitting ? 'Submitting…' : 'Submit'}
</Button>
</TeenagerLedProvider>
</FormContainer>
</Grid>
>
<Icon
size={24}
glyph="inserter"
style={{ transform: 'rotate(180deg)' }}
/>
Back
</Text>
</Link>
<Heading as="h1" variant="headline">
Turn your ideas into
<br />
reality with{' '}
<Flex
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
verticalAlign: 'middle'
}}
>
<img
src="/fiscal-sponsorship/hcb-icon-small.png"
width={48}
height={48}
alt="HCB logo"
style={{
width: '1em',
height: '1em',
verticalAlign: 'baseline'
}}
/>{' '}
HCB
</Flex>
</Heading>
<Text variant="caption">
Use HCB to raise nonprofit dollars to fund your project.
</Text>
</Box>
<HCBInfo />
<ContactBanner
sx={{ borderRadius: 'default', bg: 'snow', width: 'fit-content' }}
/>
</Flex>
<ApplicationForm />
</Grid>
</MultiStepProvider>
<Watermark />
</>
)

View file

@ -213,7 +213,7 @@ export default function Page() {
}}
>
<Stat value="$30M+" label="processed transactions" reversed />
<Stat value="2000+" label="projects" reversed />
<Stat value="6500+" label="projects" reversed />
<Stat value="2018" label="serving nonprofits since" reversed />
</Flex>
<Grid columns={[1, 2]} gap={[3, 4]} sx={{ mt: 4 }}>
@ -439,7 +439,7 @@ export default function Page() {
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0M2.04 4.326c.325 1.329 2.532 2.54 3.717 3.19.48.263.793.434.743.484q-.121.12-.242.234c-.416.396-.787.749-.758 1.266.035.634.618.824 1.214 1.017.577.188 1.168.38 1.286.983.082.417-.075.988-.22 1.52-.215.782-.406 1.48.22 1.48 1.5-.5 3.798-3.186 4-5 .138-1.243-2-2-3.5-2.5-.478-.16-.755.081-.99.284-.172.15-.322.279-.51.216-.445-.148-2.5-2-1.5-2.5.78-.39.952-.171 1.227.182.078.099.163.208.273.318.609.304.662-.132.723-.633.039-.322.081-.671.277-.867.434-.434 1.265-.791 2.028-1.12.712-.306 1.365-.587 1.579-.88A7 7 0 1 1 2.04 4.327Z" />
</svg>
<span>
As part of our commitment to climate justice, funding for HCBs
As part of our commitment to the environment, funding for HCBs
operations&nbsp;and staff will never come from the{' '}
<UILink
href="https://www.ffisolutions.com/the-carbon-underground-200-500/"

View file

@ -40,8 +40,7 @@ import Comma from '../components/comma'
import Haxidraw from '../components/index/cards/haxidraw'
import Onboard from '../components/index/cards/onboard'
import Trail from '../components/index/cards/trail'
import HighSeas from '../components/index/cards/highseas'
import Counterspell from '../components/index/cards/counterspell'
import Scrapyard from '../components/index/cards/scrapyard'
/** @jsxImportSource theme-ui */
function Page({
@ -669,8 +668,7 @@ function Page({
and make things together!
</Text>
</Box>
<HighSeas />
<Counterspell />
<Scrapyard />
<Trail />
<Slack slackKey={slackKey} data={slackData} events={events} />
</Box>

View file

@ -685,6 +685,51 @@ const Philanthropy = ({ posts = [] }) => {
firm and has audited financials through the current fiscal year.
</span>
</Fade>
<Line />
<Fade delay={100}>
<Flex sx={{ justifyContent: 'space-between' }} mt={[3, 4]}>
<Box>
<Text as="h2">View Hack Club's Annual Reports</Text>
<Text as="p">2024 Report will be shared when ready.</Text>
</Box>
<Box>
<Button
as="a"
variant="outline"
href="https://cloud-qfyq0eotn-hack-club-bot.vercel.app/02023_annual_report.pdf"
target="_blank"
mb={4}
sx={{
fontSize: '1em !important',
width: 'fit-content',
float: 'right',
mt: 2
}}
>
2023
</Button>
<Button
as="a"
variant="outline"
href="https://cloud-8zfcx3ub5-hack-club-bot.vercel.app/0hack_club_annual_report_2022.pdf"
target="_blank"
mb={4}
sx={{
fontSize: '1em !important',
width: 'fit-content',
float: 'right',
mt: 2,
mr: 2
}}
>
2022
</Button>
</Box>
</Flex>
<span>
Explore Hack Club's annual reports from 2022 onward, showcasing each year's impact and key milestones.
</span>
</Fade>
{/* <Fade delay={300}>
<Text
as="a"