mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
/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.
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
'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'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>
|
||
)
|
||
}
|