mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Calculator, captable, careers, cloud, compare, contact and contact-sales, customers, dashboards, dataroom, datastore, dev, download, enso, enterprise, extension, family, integrations, investors, kai, learn, legal and their components say their words through t() in client code, <T> in server components and phrase() where copy is held as data; one sentence is one key, figures and links its arguments. Rebased on main: in every conflict main's file stands (its copy, layout and styling) and only the wrapping is added back, including copy main added since. Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
86 lines
3.4 KiB
TypeScript
86 lines
3.4 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* What both careers forms add to an application: a resume FILE and the one opt-in.
|
|
*
|
|
* The file goes up before the application, straight to Hanzo's private careers
|
|
* bucket through a form cloud signs for that one file (POST /v1/marketing/leads/resume:
|
|
* a PDF, DOC or DOCX of at most 10 MB); the application then names its key as
|
|
* `resume_file`. A link still works instead. The box is unticked: only an applicant who
|
|
* ticks it is kept posted (`consent`).
|
|
*/
|
|
import { Text, XStack, YStack } from '@hanzo/ui'
|
|
import { api } from '@hanzo/app/lib/api'
|
|
import { phrase } from '@hanzo/i18n'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
|
|
const INK = 'var(--foreground)'
|
|
|
|
export const KEEP_POSTED = phrase('Keep me posted about roles and news at Hanzo. Unsubscribe any time.')
|
|
|
|
const TYPES: Record<string, string> = {
|
|
pdf: 'application/pdf',
|
|
doc: 'application/msword',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
}
|
|
|
|
/** Why a file cannot be a resume, or '' when it can. */
|
|
export function resumeProblem(f: File): string {
|
|
const t = TYPES[f.name.split('.').pop()?.toLowerCase() ?? '']
|
|
if (!t) return phrase('A resume is a PDF, DOC or DOCX file.')
|
|
if (f.size > 10 * 1024 * 1024) return phrase('A resume is at most 10 MB.')
|
|
return ''
|
|
}
|
|
|
|
/** Upload the resume and answer the key the application names. */
|
|
export async function uploadResume(f: File): Promise<string> {
|
|
const type = TYPES[f.name.split('.').pop()?.toLowerCase() ?? '']
|
|
const r = await fetch(`${api()}/v1/marketing/leads/resume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: f.name, type, size: f.size }),
|
|
})
|
|
if (!r.ok) throw new Error(`resume form HTTP ${r.status}`)
|
|
const form = (await r.json()) as { url: string; fields: Record<string, string>; key: string }
|
|
const body = new FormData()
|
|
for (const [k, v] of Object.entries(form.fields)) body.append(k, v)
|
|
body.append('file', new Blob([f], { type }), f.name)
|
|
const up = await fetch(form.url, { method: 'POST', body })
|
|
if (!up.ok) throw new Error(`resume upload HTTP ${up.status}`)
|
|
return form.key
|
|
}
|
|
|
|
/** The file picker and the opt-in, as both forms show them. */
|
|
export function ResumeAndConsent({ file, onFile, consent, onConsent }: {
|
|
file: File | null
|
|
onFile: (f: File | null) => void
|
|
consent: boolean
|
|
onConsent: (on: boolean) => void
|
|
}) {
|
|
const { t } = useI18n()
|
|
return (
|
|
<YStack gap="$3">
|
|
<YStack gap="$1">
|
|
<Text fontSize="$2" fontWeight="500" color={INK}>{t('Resume file')}</Text>
|
|
<Text
|
|
render={
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
aria-label={t('Resume file')}
|
|
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
}
|
|
color="inherit"
|
|
fontSize="$3"
|
|
/>
|
|
<Text fontSize="$1" opacity={0.7} color={INK}>{file ? file.name : t('PDF, DOC or DOCX, up to 10 MB. Or give a link above.')}</Text>
|
|
</YStack>
|
|
<XStack gap="$2" items="center" render="label">
|
|
<input type="checkbox" checked={consent} onChange={(e) => onConsent(e.target.checked)} aria-label={t(KEEP_POSTED)} />
|
|
<Text fontSize="$2" color={INK}>{t(KEEP_POSTED)}</Text>
|
|
</XStack>
|
|
</YStack>
|
|
)
|
|
}
|