Files
zeekayandGitHub 7843241e3f style: the last inline styles are gui props, a kit member or @hanzo/ui (#108)
* 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
2026-10-30 06:12:18 -07:00

512 lines
22 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 { 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>
)
}