Files
zeekay e981114824 chat: a signed-out reader asks the buying agent, and it routes them to pay
/chat signed out is the buying agent (components/sales/Guest, Agent): the
question the home composer carried in ?q= is asked of cloud's
POST /v1/ai/sales, the reply is drawn as text, and each next step the
server built is a control: a call (the lead is filed first, then the
calendar opens on it), the sign-up, a plan's card checkout, Add funds. A
quiet line says it is a limited preview and how many messages are left; at
the ceiling the sign-up takes the composer's place. A conversation's own
address still signs in, and a signed-in reader keeps the app.

/buying-specialist is the same agent; its keyword script, its typed prices
and its claims to see an account are gone. The model pages' no-key card is
the real lane, POST /v1/chat/public, not a guest key that answers 401.

Measured in the site's names: chat_started, cta_clicked {cta: kind, plan,
agent: sales}, sales_contacted. The floating privacy button is hidden as
room.css meant: the rule names its tag to outrank gui's atomic class.
2026-10-27 01:29:38 -07:00

471 lines
17 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'
/**
* The buying agent, for a reader with no account: Hanzo's own answers about
* plans, prices and how to buy, and the one next step that fits.
*
* THE AGENT IS CLOUD'S (`POST /v1/ai/sales`, hanzo-inc/cloud apps/ai sales.go).
* It answers from the plan catalog commerce sells from and names each next step
* as a link it built: a call with the team (`book`), the sign-up (`signup`), a
* plan's card checkout (`checkout`), or Add funds (`topup`). This file draws the
* conversation and the steps; it writes no price and no address of its own, and
* what the model said is drawn as text, never as markup or a link.
*
* A LIMITED FREE PREVIEW. One address may send a few messages a day, and the
* answer says how many are left, which the quiet line under the composer shows.
* Past it the server answers 429 and the sign-up takes the composer's place.
*
* A booking files the lead first, in Hanzo's own CRM (`POST /v1/marketing/leads`,
* the action's `lead`), with the visit the page knows, the way /contact-sales
* does; its answer's `intro_call` is the calendar opened on that lead. A lead that
* cannot be filed still books: the calendar opens with the details typed in.
*
* Measured, in the site's own names (lib/analytics `track`): `chat_started` when
* the first question goes, `cta_clicked` ({page, cta: the step's kind, plan,
* agent: 'sales'}) when the reader takes a step, and for a booking
* `sales_contacted`, mirrored under the lead's own id as /contact-sales does, so a
* conversion from the agent is counted where it happens (e2e/gates/agent.spec.ts).
*/
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { ArrowUp, Calendar, CreditCard, Sparkles, UserPlus, Wallet } from 'lucide-react'
import { Box, Text, View } from '@hanzo/ui'
import { EVENTS } from '@hanzo/event'
import { api } from '@hanzo/app/lib/api'
import { signUp } from '@hanzo/app/lib/destination'
import { mirror, track, visit } from '@hanzo/app/lib/tags'
/** One next step, as the server built it. */
export interface Step {
kind: 'book' | 'signup' | 'checkout' | 'topup'
label: string
url: string
lead?: string
plan?: string
seats?: number
interval?: string
agent?: string
}
interface Turn {
role: 'user' | 'assistant'
content: string
steps?: Step[]
}
/** What one address has left today. */
interface Standing {
left: number
limit: number
}
/** The server's refusal at the ceiling. */
interface Held {
code: string
signup: string
}
const ICON = { book: Calendar, signup: UserPlus, checkout: CreditCard, topup: Wallet } as const
/** The reader took one of the agent's steps: the site's call-to-action moment. */
const clicked = (page: string, cta: string, plan?: string) => track('cta_clicked', { page, cta, plan, agent: 'sales' })
/** Ask the agent; the turns are the conversation so far, ending with the question. */
async function ask(turns: Turn[]): Promise<Response> {
return fetch(`${api()}/v1/ai/sales`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: turns.map(({ role, content }) => ({ role, content })) }),
})
}
export function Agent({ seed, source, starters = [] }: { seed?: string; source: string; starters?: string[] }) {
const [turns, setTurns] = useState<Turn[]>([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [standing, setStanding] = useState<Standing | null>(null)
const [held, setHeld] = useState<Held | null>(null)
const [fault, setFault] = useState('')
const end = useRef<HTMLDivElement>(null)
const started = useRef(false)
useEffect(() => {
end.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [turns, busy, held])
const send = useCallback(
async (text: string) => {
const question = text.trim()
if (!question || busy || held) return
if (!started.current) {
started.current = true
track(EVENTS.CHAT_STARTED, { source, agent: 'sales' })
}
const asked: Turn[] = [...turns, { role: 'user', content: question }]
setTurns(asked)
setDraft('')
setFault('')
setBusy(true)
try {
const r = await ask(asked)
const body = (await r.json().catch(() => ({}))) as Record<string, unknown>
if (r.ok) {
setTurns([...asked, { role: 'assistant', content: String(body.reply ?? ''), steps: (body.actions as Step[]) ?? [] }])
setStanding({ left: Number(body.left ?? 0), limit: Number(body.limit ?? 0) })
} else if (r.status === 429) {
setHeld({ code: String(body.code ?? ''), signup: typeof body.signupUrl === 'string' ? body.signupUrl : signUp() })
setStanding((s) => (s ? { ...s, left: 0 } : s))
} else {
setFault('The preview could not answer that. Try again.')
}
} catch {
setFault('The preview could not be reached. Try again.')
} finally {
setBusy(false)
}
},
[busy, held, source, turns],
)
// A question carried in from elsewhere (`/chat?q=`) is asked once.
const seeded = useRef(false)
useEffect(() => {
if (seeded.current || !seed?.trim()) return
seeded.current = true
void send(seed)
}, [seed, send])
const submit = (e: FormEvent) => {
e.preventDefault()
void send(draft)
}
const last = turns.filter((t) => t.role === 'user').at(-1)?.content ?? ''
return (
<Box display="flex" flexDirection="column" height="100%" width="100%" data-agent="">
<View
render="main"
flex={1}
width="100%"
aria-live="polite"
$platform-web={{ overflowY: 'auto', boxSizing: 'border-box' }}
>
<Box display="flex" flexDirection="column" gap={20} width="100%" maxW={720} mx="auto" px={16} pt={24} pb={24}>
{turns.length === 0 ? <Welcome starters={starters} pick={(s) => void send(s)} /> : null}
{turns.map((t, i) =>
t.role === 'user' ? (
<Box
key={i}
self="flex-end"
maxW="85%"
bg="var(--muted)"
rounded={16}
py={10}
px={16}
data-turn="user"
$platform-web={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: 'var(--foreground)', fontSize: 'var(--text-base)', lineHeight: '1.5' }}
>
{t.content}
</Box>
) : (
<Box key={i} display="flex" flexDirection="column" gap={12} data-turn="assistant">
<Box
$platform-web={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: 'var(--foreground)', fontSize: 'var(--text-base)', lineHeight: '1.6' }}
>
{t.content}
</Box>
{t.steps?.length ? (
<Box display="flex" flexDirection="column" gap={10}>
{t.steps.map((s) => (
<Take key={s.kind} step={s} source={source} need={last} />
))}
</Box>
) : null}
</Box>
),
)}
{busy ? (
<Box display="flex" flexDirection="row" items="center" gap={8} $platform-web={{ color: 'var(--muted-foreground)', fontSize: 'var(--text-sm)' }}>
<Sparkles size={14} aria-hidden />
<span>Thinking</span>
</Box>
) : null}
{fault ? (
<Text render="p" m={0} fontSize="$2" color="var(--muted-foreground)" role="status">
{fault}
</Text>
) : null}
{held ? <Spent held={held} source={source} /> : null}
<div ref={end} />
</Box>
</View>
<Box width="100%" px={16} pt={8} pb={16} borderTopWidth={1} borderColor="var(--border)">
<View
render={<form onSubmit={submit} aria-label="Ask about Hanzo" />}
width="100%"
maxW={720}
mx="auto"
position="relative"
display="flex"
flexDirection="row"
items="center"
>
<View
render={
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={held ? 'Sign up to keep going' : 'Ask about plans, pricing or the API'}
aria-label="Your question"
maxLength={2000}
disabled={Boolean(held)}
/>
}
width="100%"
height={52}
bg="var(--muted)"
borderWidth={1}
borderColor="var(--border)"
rounded={9999}
pl={20}
pr={56}
$platform-web={{ outline: 'none', boxSizing: 'border-box', fontSize: 'var(--text-base)', color: 'var(--foreground)' }}
/>
<View
render={<button type="submit" aria-label="Send" disabled={busy || Boolean(held) || !draft.trim()} />}
position="absolute"
r={7}
width={38}
height={38}
rounded={9999}
bg="var(--foreground)"
borderWidth={0}
cursor="pointer"
display="flex"
items="center"
justify="center"
$platform-web={{ color: 'var(--background)', opacity: busy || held || !draft.trim() ? 0.4 : 1 }}
>
<ArrowUp size={18} strokeWidth={2.5} />
</View>
</View>
<Text
render="p"
display="block"
width="100%"
maxW={720}
mx="auto"
mb={0}
mt={8}
fontSize="$1"
color="var(--muted-foreground)"
data-preview=""
$platform-web={{ textAlign: 'center' }}
>
{standing
? `Limited free preview · ${standing.left} of ${standing.limit} messages left today`
: 'Limited free preview · no account needed'}
</Text>
</Box>
</Box>
)
}
/** The empty conversation: what the agent is for, and a few first questions. */
function Welcome({ starters, pick }: { starters: string[]; pick: (s: string) => void }) {
return (
<Box display="flex" flexDirection="column" gap={16} pt={24}>
<Text render="h1" m={0} fontSize="$8" fontWeight="600" color="var(--foreground)" $platform-web={{ lineHeight: '1.2', letterSpacing: '-0.02em' }}>
Ask before you buy.
</Text>
<Text render="p" m={0} fontSize="$4" color="var(--muted-foreground)" $platform-web={{ lineHeight: '1.55' }}>
Hanzo&apos;s buying agent answers questions about plans, prices and the API, and points you to the next step: a call with
the team, a free account, a plan, or funds for API usage. It cannot see an account or buy anything for you.
</Text>
{starters.length ? (
<Box display="flex" flexDirection="row" flexWrap="wrap" gap={8}>
{starters.map((s) => (
<View
key={s}
render={<button type="button" onClick={() => pick(s)} />}
bg="var(--card)"
borderWidth={1}
borderColor="var(--border)"
rounded={9999}
py={8}
px={14}
cursor="pointer"
$platform-web={{ color: 'var(--foreground)', fontSize: 'var(--text-sm)' }}
>
{s}
</View>
))}
</Box>
) : null}
</Box>
)
}
/** A step to take: a link, counted as the agent's handoff; a booking asks who first. */
function Take({ step, source, need }: { step: Step; source: string; need: string }) {
const [asking, setAsking] = useState(false)
const Icon = ICON[step.kind] ?? Sparkles
const handoff = () => clicked(source, step.kind, step.plan)
if (step.kind === 'book' && step.lead) {
return asking ? (
<Book step={step} source={source} need={need} />
) : (
<Button
onPress={() => {
handoff()
setAsking(true)
}}
icon={<Icon size={16} aria-hidden />}
label={step.label}
/>
)
}
return <Button href={step.url} onPress={handoff} icon={<Icon size={16} aria-hidden />} label={step.label} />
}
/** One step's control: a link when it leaves, a button when it opens something here. */
function Button({ href, onPress, icon, label }: { href?: string; onPress: () => void; icon: React.ReactNode; label: string }) {
return (
<View
render={href ? <a href={href} onClick={onPress} data-step="" /> : <button type="button" onClick={onPress} data-step="" />}
self="flex-start"
display="inline-flex"
flexDirection="row"
items="center"
gap={8}
minH={44}
py={10}
px={18}
rounded={9999}
bg="var(--foreground)"
borderWidth={0}
cursor="pointer"
$platform-web={{ color: 'var(--background)', fontSize: 'var(--text-sm)', fontWeight: '600', textDecoration: 'none' }}
>
{icon}
{label}
</View>
)
}
/**
* Who is booking: name, work email and company, filed as a lead in Hanzo's CRM
* before the calendar opens on it.
*/
function Book({ step, source, need }: { step: Step; source: string; need: string }) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [company, setCompany] = useState('')
const [busy, setBusy] = useState(false)
const ok = name.trim() !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) && company.trim() !== ''
const go = async (e: FormEvent) => {
e.preventDefault()
if (!ok || busy || !step.lead) return
setBusy(true)
const seen = visit()
const moment = { lead_source: `agent_${source}` }
let to = `${step.url}?${new URLSearchParams({ name: name.trim(), email: email.trim(), notes: `${company.trim()} · ${need}`.slice(0, 500) })}`
let lead = ''
try {
const r = await fetch(step.lead, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), email: email.trim(), company: company.trim(), need, source: `hanzo.ai/${source}`, visit: seen }),
})
const filed = r.ok ? ((await r.json()) as { lead?: string; intro_call?: string }) : {}
lead = typeof filed.lead === 'string' ? filed.lead : ''
if (typeof filed.intro_call === 'string' && filed.intro_call.startsWith(`${step.url}?`)) to = filed.intro_call
} catch {
// Unfiled, the calendar still opens with the details typed in.
}
// The CRM stated a filed lead itself; the ad tags hear it under the lead's id.
if (lead) mirror('sales_contacted', { ...moment, lead_id: lead }, lead, seen.tags as string)
else track('sales_contacted', moment)
window.location.assign(to)
}
const field = (label: string, value: string, set: (v: string) => void, type = 'text', auto = '') => (
<View
render={<input type={type} value={value} onChange={(e) => set(e.target.value)} placeholder={label} aria-label={label} autoComplete={auto} required />}
width="100%"
height={44}
bg="var(--muted)"
borderWidth={1}
borderColor="var(--border)"
rounded={12}
px={14}
$platform-web={{ outline: 'none', boxSizing: 'border-box', fontSize: 'var(--text-base)', color: 'var(--foreground)' }}
/>
)
return (
<View
render={<form onSubmit={go} aria-label="Book a call" />}
display="flex"
flexDirection="column"
gap={10}
width="100%"
maxW={420}
p={16}
rounded={16}
borderWidth={1}
borderColor="var(--border)"
bg="var(--card)"
>
<Text render="p" m={0} fontSize="$2" color="var(--muted-foreground)">
Who should the team expect?
</Text>
{field('Your name', name, setName, 'text', 'name')}
{field('Work email', email, setEmail, 'email', 'email')}
{field('Company', company, setCompany, 'text', 'organization')}
<View
render={<button type="submit" disabled={!ok || busy} />}
minH={44}
rounded={9999}
bg="var(--foreground)"
borderWidth={0}
cursor="pointer"
display="flex"
flexDirection="row"
items="center"
justify="center"
gap={8}
$platform-web={{ color: 'var(--background)', fontSize: 'var(--text-sm)', fontWeight: '600', opacity: !ok || busy ? 0.5 : 1 }}
>
<Calendar size={16} aria-hidden />
{busy ? 'Opening the calendar' : 'Pick a time'}
</View>
</View>
)
}
/** The ceiling: today's free messages are spent, and the sign-up is the way on. */
function Spent({ held, source }: { held: Held; source: string }) {
return (
<Box display="flex" flexDirection="column" gap={12} p={16} rounded={16} borderWidth={1} borderColor="var(--border)" bg="var(--card)" data-held={held.code}>
<Text render="p" m={0} fontSize="$3" color="var(--foreground)">
{held.code === 'guest_busy'
? 'The free preview has answered everyone it can today.'
: 'That is today’s free messages from this address.'}
</Text>
<Text render="p" m={0} fontSize="$2" color="var(--muted-foreground)">
A free account keeps going on the free models, and a plan or funds go further.
</Text>
<Button
href={held.signup}
onPress={() => clicked(source, 'signup')}
icon={<UserPlus size={16} aria-hidden />}
label="Sign up free"
/>
</Box>
)
}