mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
* style: the last inline styles are gui props, a kit member or @hanzo/ui
Owner rule, 2026-10-22: no style={…}, and no `inline-style:` exception.
hanzo-design.allow.json now allows 0 inline styles (from 36), 0 raw
font sizes (16), 0 raw z-indexes (1) and 42 raw colours (57).
- benchmarks/CodeRace: three lanes of hand-written divs are Lane, Track
and Dot on gui props; the hues are stated once and each wash and edge
is mixed from its hue.
- beta: the share card's picture is beta/card's Picture, used by the
share step and /beta/for/<topic>.
- /contact-sales: the options take @hanzo/ui Picker's `placeholder`
(8.29.31), which keeps the open list at full ink.
- components/code: each span says `data-code` and @hanzo/ui's new
styles/code.css inks it from the code theme; the dead Dracula
legibility pass goes with the per-token style.
- /analytics: the dashboard grows on a CSS view timeline (motion.css
`grow`) where framer wrote MotionValues per frame. A Band clips with
`overflow: clip`: `hidden` made it a scroller that never moves, and a
scroll-driven motion inside it stood half done.
- /buying-specialist: hovers and focus are hoverStyle and focusStyle
where handlers wrote them onto the element, and a reply's marks are
elements where they were HTML strings. A reply carries the name from
the address (`?name=`), which was parsed as HTML.
- ShareWidget's menu stands on --z-dropdown.
LLM.md drops the clause that allowed an `inline-style:` comment.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* gates/code: a block's spans say data-code and carry no style of their own
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* CodeRace: each part of a row may shrink, as its div did; /contact-sales' Picker inks in $ink
A gui stack keeps its width where a div gave way, so at 390 the target
query and a lane's name pushed the race 136px past the window. The
sizes Picker's choice and list take $ink, as the page drew them.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* deps: @hanzo/ui 8.29.35 and @hanzo/design 0.5.39; code samples keep their faces
@hanzo/ui 8.29.35 marks a span's weight and slant as data-face
(codeFace), so a keyword stays bold and a comment italic with no style
on the span; 8.29.34's grow is useScroll's range exactly, and Picker's
`color` is the field's ink. @hanzo/design 0.5.39 passes no
`inline-style:` comment, and the count stays 0. The corner names
8.29.33's Plex Arabic face. The allowance's prose says the numbers it
holds (redeclared-token is 0 since the bar's rungs went).
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* gui.css is the one @hanzo/ui 8.29.35 generates ($serif)
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
* deps: @hanzo/design stays 0.5.35
0.5.38's Arabic faces lead --font-sans with Noto Kufi Arabic, which
font.spec reads as the one binding moving; that bump is the
arabic-faces branch's to make. No inline-style comment is left, so the
zero holds under 0.5.39's rule whenever it lands.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
512 lines
22 KiB
TypeScript
512 lines
22 KiB
TypeScript
'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 { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/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 = [
|
||
phrase('Run Hanzo in your cloud, on your servers or fully offline'),
|
||
phrase('Dedicated compute, and your own models trained and hosted'),
|
||
phrase('Unlimited members, single sign-on, roles and an audit log'),
|
||
phrase('An uptime guarantee and security review in your contract'),
|
||
phrase('24/7 engineering support and hands-on onboarding'),
|
||
]
|
||
|
||
/** The headcount bands, as the CRM's lead records them (apps/crm Sizes), and each as a reader reads it. */
|
||
const SIZES: [string, string][] = [
|
||
['1-49', '1–49'],
|
||
['50-249', '50–249'],
|
||
['250-999', '250–999'],
|
||
['1000-4999', '1,000–4,999'],
|
||
['5000+', '5,000+'],
|
||
]
|
||
|
||
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: phrase('Enter your name.'),
|
||
email: phrase('Enter a work email, like [email protected].'),
|
||
company: phrase('Enter your company.'),
|
||
size: phrase('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 { t } = useI18n()
|
||
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={t('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>{t('Talk to sales')}</Display>
|
||
<YStack maxW={460} gap="$2">
|
||
<Lede>{t('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)">
|
||
{t('Want instant recommendations or team seat pricing?')}{' '}
|
||
<TextLink href="/buying-specialist">{t('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={t('Talk to sales')}>
|
||
<YStack gap="$4">
|
||
<Ask id="sales-name" label={t('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={t('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={t('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={t('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}
|
||
placeholder={t('Choose')}
|
||
color="$ink"
|
||
>
|
||
{SIZES.map(([v, range]) => (
|
||
<option key={v} value={v}>
|
||
{t('{range} people', { range })}
|
||
</option>
|
||
))}
|
||
</Picker>
|
||
</Choice>
|
||
</Ask>
|
||
</View>
|
||
<Ask id="sales-need" label={t('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={t('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 ? t('Sending…') : t('Talk to sales')}
|
||
</Action>
|
||
<XStack justify="center">
|
||
<More href={INTRO} target="_blank" rel="noopener noreferrer" minH={44}>
|
||
<Calendar width={16} height={16} aria-hidden />
|
||
{t('Or book a 30-minute call')}
|
||
</More>
|
||
</XStack>
|
||
</YStack>
|
||
</YStack>
|
||
</form>
|
||
)}
|
||
</YStack>
|
||
<Text render="p" text="center" fontSize="$2" color="var(--muted-foreground)">
|
||
{t('Prefer email? {mailbox}.', { mailbox: <TextLink href="mailto:[email protected]">enterprise@hanzo.ai</TextLink> })}{' '}
|
||
{t('Want it today? {team}.', { team: <TextLink href={START}>{t('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">
|
||
{t('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">
|
||
{t(says)}
|
||
</Text>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
<More href="/enterprise" self="flex-start">
|
||
{t('See everything in Enterprise')}
|
||
<ArrowRight width={14} height={14} aria-hidden data-i18n-mirror />
|
||
</More>
|
||
</YStack>
|
||
<YStack gap="$4" minW={0} maxW="100%" overflow="hidden">
|
||
<Text render="p" fontSize="$2" color="var(--muted-foreground)">
|
||
{t('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 }) {
|
||
const { t } = useI18n()
|
||
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)' }}>
|
||
{t(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 { t } = useI18n()
|
||
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">
|
||
{first ? t('Thanks, {first}. Your request is in.', { first }) : t('Thanks. Your request is in.')}
|
||
</Text>
|
||
<Text render="p" fontSize="$3" lineHeight="1.5rem" color="var(--muted-foreground)">
|
||
{t('Our team will reply to {email}. Want to talk sooner? Pick a time for a 30-minute call.', { email: answers.email.trim() })}
|
||
</Text>
|
||
<Action href={booking(answers, lead)} target="_blank" rel="noopener noreferrer" fill {...PILL} width="100%" mt="$2">
|
||
<Calendar width={18} height={18} aria-hidden />
|
||
{t('Book a call')}
|
||
</Action>
|
||
<More href="/enterprise" self="center">
|
||
{t('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 }) {
|
||
const { t } = useI18n()
|
||
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">
|
||
{t('One more step')}
|
||
</Text>
|
||
<Text render="p" fontSize="$3" lineHeight="1.5rem" color="var(--muted-foreground)">
|
||
{t('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">
|
||
{t('Pick a time')}
|
||
</Action>
|
||
<More href={letter(answers)} self="center">
|
||
{t('Or email {mailbox} with your answers', { mailbox: MAILBOX })}
|
||
</More>
|
||
</YStack>
|
||
)
|
||
}
|