Files
zeekay 4382b0cfcf booking: a call with the team is cal.com/hanzo
BOOKING is Hanzo AI's page at cal.com, where a visitor picks the 15- or
30-minute call. The sales form and "Book a 30-minute call" open INTRO, the
30-minute event page, which takes the name, email and notes the form fills
in; the profile does not. A click to cal.com/hanzo is the sales_contacted
moment, as cal.hanzo.ai was.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-25 18:16:35 -07:00

509 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* /contact-sales — the one sales page: why Enterprise on the left, one short
* form on the right.
*
* Five questions: name, work email, company, team size and what they want to
* build. Submitting files a lead in Hanzo's own CRM (`POST /v1/marketing/leads`,
* cloud apps/marketing/leads.go), which states the lead on the event plane under
* the lead's own id, carrying the visit (lib/analytics/tags.ts `visit`: consent,
* the ad click, the browser ids) so cloud forwards it to Google and Meta. The
* page mirrors the same moment to the ad tags under that id (`mirror`) and says,
* in the card, that the request is in, with a call to book at cal.com/hanzo
* filled in with their details and the lead's reference.
*
* The lead also carries the campaign the visit arrived with (the utm_* tags,
* lib/campaign `brought`) and the offer code the page holds (lib/coupon), so
* sales sees where it came from.
*
* A lead that cannot be filed (cloud answers 503 until the brand org installs
* the CRM) still counts (`track`) and still reaches a person: the card offers
* the intro call and an email, both carrying the answers.
*
* Spam: the hidden `website` field, read off the form at submit. Whatever
* fills it is shown the same thanks a person sees, nothing is filed, and it is
* counted as `sales_trapped`. Per-client rate limits are the ingress's
* (leads.go). The submit is disabled until the page runs, so the export's form
* never sends the answers as an address.
*
* Two columns from `$lg`; below that the heading, then the form, then the
* rest, because the form is why the visitor came.
*/
import { useEffect, useState, useSyncExternalStore, type FormEvent, type ReactNode } from 'react'
import { HanzoMark } from '@hanzogui/shell'
import { Input, Label, Picker, Text, Textarea, View, XStack, YStack } from '@hanzo/ui'
import { ArrowRight, Calendar, Check, ChevronDown, CircleCheck } from 'lucide-react'
import { INTRO } from '@/components/home/nav-data'
import { TrustedMarquee } from '@/components/home/Trusted'
import { MAILBOX, PILL } from '@/components/sales/kit'
import { brought } from '@/lib/campaign'
import { clean, kept } from '@/lib/coupon'
import { mirror, track, visit } from '@hanzo/app/lib/tags'
import { api } from '@hanzo/app/lib/api'
import { START } from '@hanzo/app/lib/host'
import { Action, Display, Lede, More, TextLink } from '@hanzo/ui/marketing'
/** What Enterprise includes, in the plan's own terms (GET /v1/billing/plans, `enterprise`). */
const INCLUDES = [
'Run Hanzo in your cloud, on your servers or fully offline',
'Dedicated compute, and your own models trained and hosted',
'Unlimited members, single sign-on, roles and an audit log',
'An uptime guarantee and security review in your contract',
'24/7 engineering support and hands-on onboarding',
] as const
/** The headcount bands, as the CRM's lead records them (apps/crm Sizes). */
const SIZES: [string, string][] = [
['1-49', '1–49 people'],
['50-249', '50–249 people'],
['250-999', '250–999 people'],
['1000-4999', '1,000–4,999 people'],
['5000+', '5,000+ people'],
]
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface Answers {
name: string
email: string
company: string
size: string
need: string
}
type Asked = Exclude<keyof Answers, 'need'>
/** What each required answer says when it is missing or malformed. */
const ASK: Record<Asked, string> = {
name: 'Enter your name.',
email: 'Enter a work email, like [email protected].',
company: 'Enter your company.',
size: 'Choose a team size.',
}
/** The answers that do not hold, in the order the form asks them. */
function check(a: Answers): Partial<Record<Asked, string>> {
const out: Partial<Record<Asked, string>> = {}
if (!a.name.trim()) out.name = ASK.name
if (!EMAIL.test(a.email.trim())) out.email = ASK.email
if (!a.company.trim()) out.company = ASK.company
if (!a.size) out.size = ASK.size
return out
}
/** The answers as one line, for a booking's notes and an email's body. */
const said = (a: Answers, lead?: string) =>
[a.company, a.size && `${a.size} people`, a.need, lead && `Lead ${lead}`].filter(Boolean).join(' · ')
/**
* Where the visit came from, as the lead records it: the utm_* tags of the link it
* arrived on and the offer code the page holds. A key with nothing to say is left
* out of the body.
*/
function arrived(): { campaign?: Record<string, string>; coupon?: string } {
const tags = brought(window.location.search).filter(([k]) => k.startsWith('utm_'))
const coupon = clean(new URLSearchParams(window.location.search).get('coupon')) ?? kept()
return { ...(tags.length ? { campaign: Object.fromEntries(tags) } : {}), ...(coupon ? { coupon } : {}) }
}
/** The calendar, opened on the intro call with the visitor's details in it. */
const booking = (a: Answers, lead?: string) => `${INTRO}?${new URLSearchParams({ name: a.name, email: a.email.trim(), notes: said(a, lead) })}`
/** An email to sales, written for the visitor from their answers. */
const letter = (a: Answers) =>
`mailto:${MAILBOX}?${new URLSearchParams({
subject: `Talk to sales: ${a.company}`,
body: `${a.name}\n${a.email.trim()}\n${a.company}\n${a.size ? `${a.size} people\n` : ''}\n${a.need}`,
})
.toString()
.replace(/\+/g, '%20')}`
/** The funnel's field (components/auth/page `FIELD`): 56px, 1rem type, 12px corners, on `--muted`. */
const FIELD = { size: 'lg', height: 56, fontSize: '1rem', rounded: 12, bg: 'var(--muted)' } as const
/**
* The native select at the same box: 56 high, its 24px line in the middle, and
* the platform's own drawing off, because WebKit draws a menu list at its own
* 26px whatever the padding says. The chevron is drawn beside it (`Choice`).
*/
const CHOICE = {
size: 'lg',
height: 56,
minH: 56,
py: 15,
pl: 14,
pr: 44,
fontSize: '1rem',
lineHeight: '1.5rem',
rounded: 12,
bg: 'var(--muted)',
'$platform-web': { appearance: 'none', WebkitAppearance: 'none' },
} as const
/** A store that never changes: `ready` reads only which side of hydration it is on. */
const still = () => () => {}
/** A field said wrong wears the error edge until it is mended, under the pointer and in focus too. */
const BAD = {
borderColor: 'var(--state-error)',
hoverStyle: { borderColor: 'var(--state-error)' },
focusStyle: { borderColor: 'var(--state-error)' },
} as const
/** The answers a page carried in its address: what a form sent before this page ran, which the address must not keep. */
const ASKED = ['name', 'email', 'company', 'size', 'need', 'website']
type State = { at: 'form' } | { at: 'sent'; lead?: string } | { at: 'held' }
export default function ContactSales() {
const [answers, setAnswers] = useState<Answers>({ name: '', email: '', company: '', size: '', need: '' })
const [wrong, setWrong] = useState<Partial<Record<Asked, string>>>({})
const [trap, setTrap] = useState('')
const [busy, setBusy] = useState(false)
const [state, setState] = useState<State>({ at: 'form' })
// The export's form is inert until this page runs: a press before then would
// send the answers as a plain GET, into the address, and file nothing. False
// in the export and through hydration, true once the page runs.
const ready = useSyncExternalStore(still, () => true, () => false)
useEffect(() => {
const q = new URLSearchParams(window.location.search)
if (!ASKED.some((k) => q.has(k))) return
for (const k of ASKED) q.delete(k)
const rest = q.toString()
window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`)
}, [])
const set = (k: keyof Answers) => (v: string) => {
setAnswers((a) => ({ ...a, [k]: v }))
// A field said wrong is cleared as it is mended, never re-judged mid-word.
if (k !== 'need' && wrong[k]) setWrong((w) => ({ ...w, [k]: undefined }))
}
/** Once the card says what happened, it holds the focus, so a screen reader reads it. */
const show = (next: State) => {
setState(next)
requestAnimationFrame(() => document.getElementById('sales-card')?.focus())
}
const send = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (busy) return
// Read off the form itself, so a value set without an input event counts.
const trapped = Boolean(new FormData(e.currentTarget).get('website') || trap)
const missing = check(answers)
setWrong(missing)
const first = (Object.keys(missing) as Asked[])[0]
if (first) {
document.getElementById(`sales-${first}`)?.focus()
return
}
// A form a person cannot see was filled in by something else: it gets the
// thanks a person gets, nothing is filed, and the moment is counted.
if (trapped) {
track('sales_trapped', { lead_source: 'contact-sales' })
return show({ at: 'sent' })
}
setBusy(true)
const seen = visit()
const moment = { lead_source: 'contact-sales', company_size: answers.size }
let lead: string | undefined
try {
const r = await fetch(`${api()}/v1/marketing/leads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...answers, email: answers.email.trim(), source: 'hanzo.ai/contact-sales', visit: seen, ...arrived() }),
})
if (r.ok) lead = ((await r.json()) as { lead?: string }).lead
} catch {
lead = undefined
}
setBusy(false)
if (!lead) {
track('sales_contacted', moment)
return show({ at: 'held' })
}
// The CRM stated it; the ad tags hear it under the lead's id, and our
// stream is not told twice.
mirror('sales_contacted', { ...moment, lead_id: lead }, lead, seen.tags as string)
show({ at: 'sent', lead })
}
return (
<YStack
render="section"
aria-label="Talk to sales"
position="relative"
overflow="hidden"
pt={112}
pb={96}
px="$4"
$md={{ px: '$6' }}
$lg={{ pt: 136, pb: 128, px: '$8' }}
>
{/* The light the page opens under, from the top right. */}
<View
aria-hidden
position="absolute"
t={0}
l={0}
r={0}
height={560}
pointerEvents="none"
backgroundImage="radial-gradient(ellipse 60% 100% at 70% 0%, var(--white-08), transparent 70%)"
/>
<View
position="relative"
display="grid"
gap="$7"
width="100%"
maxW={1152}
mx="auto"
gridTemplateAreas={"'head' 'form' 'more'"}
$lg={{
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gridTemplateRows: 'auto 1fr',
gridTemplateAreas: "'head form' 'more form'",
columnGap: 96,
rowGap: 48,
items: 'flex-start',
}}
>
<YStack gridArea="head" minW={0} gap="$4.5">
<XStack render="p" items="center" gap="$2.5" mb="$4">
<HanzoMark size={20} />
<Text fontSize="$4" fontWeight="500" color="$ink">
Enterprise
</Text>
</XStack>
<Display>Talk to sales</Display>
<YStack maxW={460} gap="$2">
<Lede>Tell us what you want to build. We come back with a plan for your company: where Hanzo runs, what it costs and how you start.</Lede>
<Text fontSize="$2" color="var(--muted-foreground)">
Want instant recommendations or team seat pricing?{' '}
<TextLink href="/buying-specialist">Talk to AI Buying Specialist →</TextLink>
</Text>
</YStack>
</YStack>
<YStack gridArea="form" minW={0} gap="$4">
<YStack
id="sales-card"
tabIndex={-1}
data-slot="sales-card"
data-state={state.at}
aria-live="polite"
p="$5"
gap="$5"
bg="$panel"
borderWidth={1}
borderColor="var(--border)"
rounded="var(--radius-2xl)"
boxShadow="var(--edge-highlight), var(--shadow-floating)"
$sm={{ p: 32 }}
$platform-web={{ outline: 'none' }}
>
{state.at === 'sent' ? (
<Sent answers={answers} lead={state.lead} />
) : state.at === 'held' ? (
<Held answers={answers} />
) : (
<form onSubmit={send} method="post" noValidate aria-label="Talk to sales">
<YStack gap="$4">
<Ask id="sales-name" label="Full name" wrong={wrong.name}>
<Input id="sales-name" {...FIELD} {...(wrong.name ? BAD : null)} name="name" autoComplete="name" value={answers.name} onChange={(e) => set('name')(e.target.value)} required aria-invalid={Boolean(wrong.name)} aria-describedby={wrong.name ? 'sales-name-wrong' : undefined} />
</Ask>
<Ask id="sales-email" label="Work email" wrong={wrong.email}>
<Input
id="sales-email"
{...FIELD}
{...(wrong.email ? BAD : null)}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={answers.email}
onChange={(e) => set('email')(e.target.value)}
onBlur={() => answers.email.trim() && !EMAIL.test(answers.email.trim()) && setWrong((w) => ({ ...w, email: ASK.email }))}
placeholder="[email protected]"
required
aria-invalid={Boolean(wrong.email)}
aria-describedby={wrong.email ? 'sales-email-wrong' : undefined}
/>
</Ask>
<View display="grid" gap="$4" $sm={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)' }}>
<Ask id="sales-company" label="Company" wrong={wrong.company}>
<Input id="sales-company" {...FIELD} {...(wrong.company ? BAD : null)} name="company" autoComplete="organization" value={answers.company} onChange={(e) => set('company')(e.target.value)} required aria-invalid={Boolean(wrong.company)} aria-describedby={wrong.company ? 'sales-company-wrong' : undefined} />
</Ask>
<Ask id="sales-size" label="Team size" wrong={wrong.size}>
<Choice>
<Picker
id="sales-size"
{...CHOICE}
{...(wrong.size ? BAD : null)}
name="size"
value={answers.size}
onChange={(e) => set('size')(e.target.value)}
required
aria-invalid={Boolean(wrong.size)}
aria-describedby={wrong.size ? 'sales-size-wrong' : undefined}
color={answers.size ? '$ink' : '$placeholderColor'}
>
<option value="" disabled>
Choose
</option>
{SIZES.map(([v, label]) => (
// inline-style: an <option> is drawn by the platform and takes no gui prop; its ink is stated so the open list reads at full strength while the closed select shows the dim hint.
<option key={v} value={v} style={{ color: 'var(--foreground)' }}>
{label}
</option>
))}
</Picker>
</Choice>
</Ask>
</View>
<Ask id="sales-need" label="What do you want to build?">
<Textarea
id="sales-need"
size="lg"
fontSize="1rem"
rounded={12}
bg="var(--muted)"
name="need"
rows={4}
value={answers.need}
onChangeText={set('need')}
placeholder="Agents for our support team, Hanzo on our own servers…"
/>
</Ask>
{/* A field no person can see: whatever fills it is not a person. */}
<View render="label" aria-hidden position="absolute" l={-9999} width={1} height={1} overflow="hidden">
<span>Leave this empty</span>
<input tabIndex={-1} autoComplete="off" name="website" value={trap} onChange={(e) => setTrap(e.target.value)} />
</View>
<YStack gap="$3" mt="$2">
<Action render="button" type="submit" fill disabled={busy || !ready} {...PILL} width="100%">
{busy ? 'Sending…' : 'Talk to sales'}
</Action>
<XStack justify="center">
<More href={INTRO} target="_blank" rel="noopener noreferrer" minH={44}>
<Calendar width={16} height={16} aria-hidden />
Or book a 30-minute call
</More>
</XStack>
</YStack>
</YStack>
</form>
)}
</YStack>
<Text render="p" text="center" fontSize="$2" color="var(--muted-foreground)">
Prefer email? <TextLink href="mailto:[email protected]">enterprise@hanzo.ai</TextLink>. Want it today?{' '}
<TextLink href={START}>Get Hanzo Team</TextLink>.
</Text>
</YStack>
<YStack gridArea="more" minW={0} gap="$8">
<YStack gap="$4">
<Text render="h2" fontSize="$4" fontWeight="600" color="$ink">
What Enterprise includes
</Text>
<YStack render="ul" data-slot="sales-features" gap="$3" m={0} p={0} $platform-web={{ listStyle: 'none' }}>
{INCLUDES.map((says) => (
<XStack render="li" key={says} items="flex-start" gap="$2.5">
<View shrink={0} mt={3}>
<Check size={16} color="var(--foreground)" aria-hidden />
</View>
<Text fontSize="$3" lineHeight="1.5rem" color="$ink">
{says}
</Text>
</XStack>
))}
</YStack>
<More href="/enterprise" self="flex-start">
See everything in Enterprise
<ArrowRight width={14} height={14} aria-hidden />
</More>
</YStack>
<YStack gap="$4" minW={0} maxW="100%" overflow="hidden">
<Text render="p" fontSize="$2" color="var(--muted-foreground)">
Trusted by
</Text>
<TrustedMarquee height={26} />
</YStack>
</YStack>
</View>
</YStack>
)
}
/** A labelled field, and what is wrong with it under it. */
function Ask({ id, label, wrong, children }: { id: string; label: string; wrong?: string; children: ReactNode }) {
return (
<YStack gap="$2" minW={0}>
<Label htmlFor={id}>{label}</Label>
{children}
{wrong ? (
<Text render="p" id={`${id}-wrong`} fontSize="$2" color="var(--state-error-text)" $theme-light={{ color: 'var(--destructive-hover)' }}>
{wrong}
</Text>
) : null}
</YStack>
)
}
/** The select and its chevron, which the platform no longer draws. */
function Choice({ children }: { children: ReactNode }) {
return (
<View position="relative" minW={0}>
{children}
<View position="absolute" r={16} t={0} b={0} justify="center" pointerEvents="none" aria-hidden>
<ChevronDown size={18} color="var(--muted-foreground)" />
</View>
</View>
)
}
/** The request is in: who will hear back, and a call to book now if they want one. */
function Sent({ answers, lead }: { answers: Answers; lead?: string }) {
const first = answers.name.trim().split(/\s+/)[0]
return (
<YStack gap="$4" items="flex-start" data-slot="sales-sent">
<CircleCheck size={32} color="var(--foreground)" aria-hidden />
<Text render="h2" fontSize="$7" lineHeight="$8" fontWeight="500" color="$ink">
Thanks{first ? `, ${first}` : ''}. Your request is in.
</Text>
<Text render="p" fontSize="$3" lineHeight="1.5rem" color="var(--muted-foreground)">
Our team will reply to {answers.email.trim()}. Want to talk sooner? Pick a time for a 30-minute call.
</Text>
<Action href={booking(answers, lead)} target="_blank" rel="noopener noreferrer" fill {...PILL} width="100%" mt="$2">
<Calendar width={18} height={18} aria-hidden />
Book a call
</Action>
<More href="/enterprise" self="center">
See everything in Enterprise
</More>
</YStack>
)
}
/** The request is not filed yet: the call or an email carries the answers to a person. */
function Held({ answers }: { answers: Answers }) {
return (
<YStack gap="$4" items="flex-start" data-slot="sales-held">
<Calendar size={32} color="var(--foreground)" aria-hidden />
<Text render="h2" fontSize="$7" lineHeight="$8" fontWeight="500" color="$ink">
One more step
</Text>
<Text render="p" fontSize="$3" lineHeight="1.5rem" color="var(--muted-foreground)">
Pick a time for a 30-minute call. Your answers go with the booking.
</Text>
<Action href={booking(answers)} target="_blank" rel="noopener noreferrer" fill {...PILL} width="100%" mt="$2">
Pick a time
</Action>
<More href={letter(answers)} self="center">
Or email {MAILBOX} with your answers
</More>
</YStack>
)
}