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
844 lines
33 KiB
TypeScript
844 lines
33 KiB
TypeScript
'use client'
|
||
|
||
import { Box, Text, View, type GuiElement } from '@hanzo/ui'
|
||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||
import Link from 'next/link'
|
||
import { ArrowLeft, ArrowUp, Sparkles, Users } from 'lucide-react'
|
||
import { useIam } from '@hanzo/iam/react'
|
||
import { useQuery } from '@/lib/auth/query'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n, type I18n } from '@hanzo/i18n/react'
|
||
|
||
interface Message {
|
||
id: string
|
||
role: 'user' | 'assistant'
|
||
content: string
|
||
quote?: {
|
||
plan: string
|
||
seats?: number
|
||
standardSeats?: number
|
||
premiumSeats?: number
|
||
monthlyTotal: number
|
||
annualTotal: number
|
||
savings: number
|
||
checkoutUrl?: string
|
||
contactSales?: boolean
|
||
}
|
||
}
|
||
|
||
/** Symmetrical neural network tree icon matching the specialist branding. */
|
||
function SpecialistIcon({ size = 56 }: { size?: number }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||
{/* Central vertical stem */}
|
||
<line x1="32" y1="16" x2="32" y2="48" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||
|
||
{/* Hexagonal branched network */}
|
||
<path d="M 32 20 L 20 27 L 20 41 L 32 47 L 44 41 L 44 27 Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
|
||
<line x1="32" y1="33" x2="20" y2="27" stroke="currentColor" strokeWidth="2" />
|
||
<line x1="32" y1="33" x2="44" y2="27" stroke="currentColor" strokeWidth="2" />
|
||
<line x1="32" y1="33" x2="20" y2="41" stroke="currentColor" strokeWidth="2" />
|
||
<line x1="32" y1="33" x2="44" y2="41" stroke="currentColor" strokeWidth="2" />
|
||
|
||
{/* Nodes */}
|
||
<circle cx="32" cy="14" r="3.5" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
<circle cx="32" cy="33" r="3" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
<circle cx="32" cy="50" r="3.5" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
|
||
<circle cx="20" cy="27" r="3" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
<circle cx="44" cy="27" r="3" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
<circle cx="20" cy="41" r="3" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
<circle cx="44" cy="41" r="3" stroke="currentColor" strokeWidth="2" fill="var(--background)" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** Formats simple markdown (bold, lists, links) into clean JSX elements. */
|
||
function FormattedText({ text }: { text: string }) {
|
||
const paragraphs = text.split('\n\n')
|
||
|
||
return (
|
||
<Box display="flex" flexDirection="column" gap={12}>
|
||
{paragraphs.map((p, pIdx) => {
|
||
const lines = p.split('\n')
|
||
const isList = lines.every((l) => l.trim().startsWith('- ') || l.trim().startsWith('* '))
|
||
|
||
if (isList) {
|
||
return (
|
||
<View
|
||
key={pIdx}
|
||
render="ul"
|
||
m={0}
|
||
pl={20}
|
||
display="flex"
|
||
flexDirection="column"
|
||
gap={6}
|
||
>
|
||
{lines.map((l, lIdx) => {
|
||
const clean = l.replace(/^[-*]\s+/, '')
|
||
return <li key={lIdx}>{inline(clean)}</li>
|
||
})}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<View key={pIdx} render="p" display="block" m={0} $platform-web={{ lineHeight: "1.6" }}>
|
||
{inline(p)}
|
||
</View>
|
||
)
|
||
})}
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
/** Code inside a line of a reply. */
|
||
function Snippet({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="code" bg="var(--white-08)" px={6} py={2} rounded={4} fontFamily="$mono" $platform-web={{ fontSize: '0.9em' }}>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The marks a reply uses inside a line — **bold**, *emphasis*, `code` — as
|
||
* elements, never as markup: a reply carries the reader's name, which arrives
|
||
* in the address, so it is text and nothing in it is ever parsed as HTML.
|
||
*/
|
||
function inline(line: string): ReactNode[] {
|
||
const out: ReactNode[] = []
|
||
let at = 0
|
||
for (const m of line.matchAll(/\*\*(.*?)\*\*|\*(.*?)\*|`(.*?)`/g)) {
|
||
if (m.index > at) out.push(line.slice(at, m.index))
|
||
const [, bold, em, code] = m
|
||
out.push(
|
||
bold !== undefined ? (
|
||
<strong key={m.index}>{bold}</strong>
|
||
) : em !== undefined ? (
|
||
<em key={m.index}>{em}</em>
|
||
) : (
|
||
<Snippet key={m.index}>{code}</Snippet>
|
||
),
|
||
)
|
||
at = m.index + m[0].length
|
||
}
|
||
if (at < line.length) out.push(line.slice(at))
|
||
return out
|
||
}
|
||
|
||
export default function BuyingSpecialistPage() {
|
||
const { t } = useI18n()
|
||
const query = useQuery()
|
||
const { user } = useIam()
|
||
|
||
// Display name from account, query param, or fallback to Zach (matching the prompt screenshot)
|
||
const name = (query.get('name') || user?.name || 'Zach').trim()
|
||
const initials = user?.name
|
||
? user.name.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase()
|
||
: user?.email
|
||
? user.email.slice(0, 2).toUpperCase()
|
||
: 'ZK'
|
||
|
||
const [input, setInput] = useState('')
|
||
const [messages, setMessages] = useState<Message[]>([])
|
||
const [isThinking, setIsThinking] = useState(false)
|
||
const messagesEndRef = useRef<GuiElement>(null)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const isChatting = messages.length > 0
|
||
|
||
useEffect(() => {
|
||
(messagesEndRef.current as HTMLElement | null)?.scrollIntoView({ behavior: 'smooth' })
|
||
}, [messages, isThinking])
|
||
|
||
// `source` is the English a suggestion was written in: the replies key off English words.
|
||
const handleStart = (initialText?: string, source?: string) => {
|
||
const userPrompt = initialText || input.trim() || t('Hi! Can you help me find the right plan for my team?')
|
||
const asked = source || initialText || input.trim() || 'Hi! Can you help me find the right plan for my team?'
|
||
setInput('')
|
||
|
||
const userMessage: Message = {
|
||
id: String(Date.now()),
|
||
role: 'user',
|
||
content: userPrompt,
|
||
}
|
||
|
||
setMessages((prev) => [...prev, userMessage])
|
||
setIsThinking(true)
|
||
|
||
setTimeout(() => {
|
||
const response = generateAssistantResponse(asked, name, t)
|
||
setMessages((prev) => [...prev, response])
|
||
setIsThinking(false)
|
||
}, 700)
|
||
}
|
||
|
||
const handleSubmit = (e: FormEvent) => {
|
||
e.preventDefault()
|
||
if (!input.trim() && !isChatting) {
|
||
handleStart()
|
||
return
|
||
}
|
||
if (!input.trim()) return
|
||
|
||
const userPrompt = input.trim()
|
||
setInput('')
|
||
|
||
const userMessage: Message = {
|
||
id: String(Date.now()),
|
||
role: 'user',
|
||
content: userPrompt,
|
||
}
|
||
|
||
setMessages((prev) => [...prev, userMessage])
|
||
setIsThinking(true)
|
||
|
||
setTimeout(() => {
|
||
const response = generateAssistantResponse(userPrompt, name, t)
|
||
setMessages((prev) => [...prev, response])
|
||
setIsThinking(false)
|
||
}, 800)
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
minH="100vh"
|
||
bg="var(--surface-card)"
|
||
display="flex"
|
||
flexDirection="column"
|
||
$platform-web={{ fontFamily: "var(--font-body, system-ui, -apple-system, sans-serif)", color: "var(--foreground)" }}
|
||
>
|
||
{/* Top Navbar */}
|
||
<View
|
||
render="header"
|
||
position="sticky"
|
||
t={0}
|
||
z="var(--z-dock)"
|
||
height={56}
|
||
borderBottomWidth={1}
|
||
borderBottomColor="var(--white-08)"
|
||
display="flex"
|
||
items="center"
|
||
justify="space-between"
|
||
py={0}
|
||
px={20}
|
||
flexDirection="row"
|
||
bg="transparent"
|
||
$platform-web={{ backdropFilter: "blur(12px)" }}
|
||
>
|
||
<Text
|
||
render={<Link href="/pricing" />}
|
||
items="center"
|
||
gap={8}
|
||
color="var(--muted-foreground)"
|
||
hoverStyle={{ color: 'var(--foreground)' }}
|
||
textDecorationLine="none"
|
||
fontSize="$3"
|
||
fontWeight="500"
|
||
$platform-web={{ display: "inline-flex", transition: "color 0.15s ease", flexDirection: "row" }}
|
||
>
|
||
<ArrowLeft size={16} data-i18n-mirror />
|
||
{t('Return to Hanzo')}
|
||
</Text>
|
||
|
||
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
|
||
{t('Buying specialist')}
|
||
</Text>
|
||
|
||
<Box
|
||
title={name}
|
||
width={32}
|
||
height={32}
|
||
rounded={9999}
|
||
bg="var(--art-iris)"
|
||
display="flex"
|
||
items="center"
|
||
justify="center"
|
||
flexDirection="row"
|
||
$platform-web={{ letterSpacing: "0.5px", color: "var(--foreground)", fontSize: "var(--text-xs)", fontWeight: "700" }}
|
||
>
|
||
{initials}
|
||
</Box>
|
||
</View>
|
||
|
||
{/* Main Body */}
|
||
<View
|
||
render="main"
|
||
flex={1}
|
||
display="flex"
|
||
flexDirection="column"
|
||
items="center"
|
||
width="100%"
|
||
maxW={680}
|
||
my={0}
|
||
mx="auto"
|
||
pt={isChatting ? 24 : 48}
|
||
px={20}
|
||
pb={140}
|
||
$platform-web={{ boxSizing: "border-box" }}
|
||
>
|
||
{!isChatting ? (
|
||
<Box display="flex" flexDirection="column" items="center" width="100%" gap={24}>
|
||
{/* Specialist Icon */}
|
||
<Box mt={12} $platform-web={{ color: "var(--neutral-300)" }}>
|
||
<SpecialistIcon size={56} />
|
||
</Box>
|
||
|
||
{/* Title & Introduction */}
|
||
<Box
|
||
display="flex"
|
||
flexDirection="column"
|
||
gap={12}
|
||
$platform-web={{ textAlign: "center" }}
|
||
>
|
||
<Text
|
||
render="h1"
|
||
display="block"
|
||
fontSize="$8"
|
||
fontWeight="600"
|
||
color="var(--foreground)"
|
||
m={0}
|
||
$platform-web={{ lineHeight: "1.25", letterSpacing: "-0.02em" }}
|
||
>
|
||
{t("Hi {name}, let's find the right plan for your team.", { name })}
|
||
</Text>
|
||
<Text
|
||
render="p"
|
||
display="block"
|
||
fontSize="$4"
|
||
color="var(--muted-foreground)"
|
||
m={0}
|
||
maxW={560}
|
||
$platform-web={{ lineHeight: "1.55" }}
|
||
>
|
||
{t('I\'m an AI buying specialist, here to help you figure out which plan fits, what it\'ll cost, and how to get your team set up. We can do all of it right here.')}
|
||
</Text>
|
||
</Box>
|
||
|
||
{/* Value Proposition List */}
|
||
<View
|
||
render="ul"
|
||
width="100%"
|
||
mt={12}
|
||
mx={0}
|
||
mb={0}
|
||
pt={0}
|
||
pr={0}
|
||
pb={0}
|
||
pl={20}
|
||
display="flex"
|
||
flexDirection="column"
|
||
gap={14}
|
||
$platform-web={{ lineHeight: "1.55", color: "var(--neutral-300)", fontSize: "var(--text-base)" }}
|
||
>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t('The fastest path in.')}</Text> {t("You'll have a quote and a plan by the end of this conversation.")}
|
||
</li>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t("The best price you'll get.")}</Text> {t("I'll check for our latest pricing.")}
|
||
</li>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t('I already know your account.')}</Text> {t("If you're using Hanzo, I can see your current plan, seats, and usage.")}
|
||
</li>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t('Free to talk.')}</Text> {t("This conversation doesn't count toward your usage.")}
|
||
</li>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t('A sales rep if you want one.')}</Text> {t("For complex or large deals, I'll hand you to a teammate with everything you've shared already in context.")}
|
||
</li>
|
||
<li>
|
||
<Text render="strong" color="var(--foreground)">{t('Nothing sensitive needed.')}</Text> {t('I can find the right plan with some basic information. Please keep confidential details out of chat.')}
|
||
</li>
|
||
</View>
|
||
|
||
{/* Privacy Box */}
|
||
<Box
|
||
width="100%"
|
||
py={14}
|
||
px={18}
|
||
rounded={12}
|
||
borderWidth={1}
|
||
borderColor="var(--white-08)"
|
||
bg="var(--white-03)"
|
||
mt={8}
|
||
$platform-web={{ boxSizing: "border-box", fontSize: "var(--text-sm)", color: "var(--muted-foreground)", textAlign: "left" }}
|
||
>
|
||
{t('By continuing, you agree to our {link}.', {
|
||
link: (
|
||
<Text
|
||
render={<Link href="/privacy" />}
|
||
display="inline"
|
||
color="var(--muted-foreground)"
|
||
textDecorationLine="underline"
|
||
>
|
||
{t('Privacy Policy')}
|
||
</Text>
|
||
),
|
||
})}
|
||
</Box>
|
||
|
||
{/* Starter Suggestions */}
|
||
<Box width="100%" display="flex" flexWrap="wrap" gap={8} mt={12} flexDirection="row">
|
||
{[
|
||
phrase('We have a team of 10 developers'),
|
||
phrase('Explain Standard vs Premium seats'),
|
||
phrase('Looking for dedicated VPC & HIPAA'),
|
||
phrase('Compare Team vs Enterprise'),
|
||
].map((suggestion) => (
|
||
<Text
|
||
key={suggestion}
|
||
render={<button type="button" onClick={() => handleStart(t(suggestion), suggestion)} />}
|
||
bg="var(--white-03)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-08)"
|
||
py={8}
|
||
px={14}
|
||
rounded={9999}
|
||
cursor="pointer"
|
||
color="var(--neutral-300)"
|
||
fontSize="$2"
|
||
hoverStyle={{ bg: 'var(--white-08)', borderColor: 'var(--white-20)', color: 'var(--foreground)' }}
|
||
$platform-web={{ transition: "all 0.15s ease", display: "inline-block" }}
|
||
>
|
||
{t('{label} →', { label: t(suggestion) })}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
) : (
|
||
/* Conversational Chat View */
|
||
<Box width="100%" display="flex" flexDirection="column" gap={20}>
|
||
{messages.map((m) => (
|
||
<Box
|
||
key={m.id}
|
||
display="flex"
|
||
flexDirection="column"
|
||
width="100%"
|
||
$platform-web={{ alignItems: m.role === 'user' ? 'flex-end' : 'flex-start' }}
|
||
>
|
||
{m.role === 'assistant' ? (
|
||
<Box display="flex" gap={12} items="flex-start" maxW="100%" flexDirection="row">
|
||
<Box
|
||
width={32}
|
||
height={32}
|
||
rounded={9999}
|
||
bg="var(--white-08)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-10)"
|
||
display="flex"
|
||
items="center"
|
||
justify="center"
|
||
shrink={0}
|
||
mt={2}
|
||
flexDirection="row"
|
||
$platform-web={{ color: "var(--neutral-300)" }}
|
||
>
|
||
<Sparkles size={16} />
|
||
</Box>
|
||
|
||
<Box display="flex" flexDirection="column" gap={14} flex={1}>
|
||
<Box
|
||
bg="var(--white-03)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-08)"
|
||
rounded={16}
|
||
py={16}
|
||
px={20}
|
||
$platform-web={{ lineHeight: "1.6", color: "var(--foreground)", fontSize: "var(--text-lg)" }}
|
||
>
|
||
<FormattedText text={m.content} />
|
||
</Box>
|
||
|
||
{/* Interactive Quote Card if present */}
|
||
{m.quote ? (
|
||
<Box
|
||
rounded={16}
|
||
p={20}
|
||
display="flex"
|
||
flexDirection="column"
|
||
gap={14}
|
||
$platform-web={{ borderWidth: "1px", borderStyle: "solid", borderColor: "color-mix(in srgb, var(--art-iris) 35%, transparent)", backgroundColor: "color-mix(in srgb, var(--art-iris) 6%, transparent)" }}
|
||
>
|
||
<Box
|
||
display="flex"
|
||
justify="space-between"
|
||
items="center"
|
||
flexDirection="row"
|
||
>
|
||
<Box display="flex" items="center" gap={8} flexDirection="row">
|
||
<Users size={18} color="var(--art-iris)" />
|
||
<Text render="strong" fontSize="$4" color="var(--foreground)">
|
||
{t('{plan} Configuration', { plan: m.quote.plan })}
|
||
</Text>
|
||
</Box>
|
||
<Text
|
||
fontSize="$1"
|
||
fontWeight="700"
|
||
py={3}
|
||
px={8}
|
||
rounded={9999}
|
||
color="var(--state-success)"
|
||
$platform-web={{ backgroundColor: "color-mix(in srgb, var(--state-success) 20%, transparent)" }}
|
||
>
|
||
{t('SAVE {percent}% ANNUALLY', { percent: m.quote.savings })}
|
||
</Text>
|
||
</Box>
|
||
|
||
<Box
|
||
gap={12}
|
||
$platform-web={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}
|
||
>
|
||
<Box
|
||
py={12}
|
||
px={14}
|
||
rounded={12}
|
||
bg="var(--white-03)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-08)"
|
||
>
|
||
<Box
|
||
$platform-web={{ fontSize: "var(--text-xs)", color: "var(--muted-foreground)" }}
|
||
>{t('Annual Rate (Recommended)')}</Box>
|
||
<Box
|
||
mt={4}
|
||
$platform-web={{ fontSize: "var(--text-2xl)", fontWeight: "700", color: "var(--foreground)" }}
|
||
>
|
||
${m.quote.annualTotal / 12}
|
||
<Text
|
||
fontSize="$1"
|
||
fontWeight="400"
|
||
color="var(--muted-foreground)"
|
||
>{t('/mo')}</Text>
|
||
</Box>
|
||
<Box
|
||
mt={2}
|
||
$platform-web={{ fontSize: "var(--text-xs)", color: "var(--muted-foreground)" }}
|
||
>
|
||
{t('Billed ${amount} annually upfront', { amount: m.quote.annualTotal })}
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box
|
||
py={12}
|
||
px={14}
|
||
rounded={12}
|
||
bg="var(--white-03)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-06)"
|
||
>
|
||
<Box
|
||
$platform-web={{ fontSize: "var(--text-xs)", color: "var(--muted-foreground)" }}
|
||
>{t('Monthly Rate')}</Box>
|
||
<Box
|
||
mt={4}
|
||
$platform-web={{ fontSize: "var(--text-2xl)", fontWeight: "700", color: "var(--foreground)" }}
|
||
>
|
||
${m.quote.monthlyTotal}
|
||
<Text
|
||
fontSize="$1"
|
||
fontWeight="400"
|
||
color="var(--muted-foreground)"
|
||
>{t('/mo')}</Text>
|
||
</Box>
|
||
<Box
|
||
mt={2}
|
||
$platform-web={{ fontSize: "var(--text-xs)", color: "var(--muted-foreground)" }}
|
||
>
|
||
{t('Cancel anytime')}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box display="flex" gap={10} mt={4} flexWrap="wrap" flexDirection="row">
|
||
{m.quote.checkoutUrl ? (
|
||
<Text
|
||
render={<Link href={m.quote.checkoutUrl} />}
|
||
flex={1}
|
||
minW={180}
|
||
py={10}
|
||
px={18}
|
||
rounded={9999}
|
||
bg="var(--foreground)"
|
||
color="var(--background)"
|
||
fontWeight="600"
|
||
fontSize="$2"
|
||
text="center"
|
||
textDecorationLine="none"
|
||
items="center"
|
||
justify="center"
|
||
gap={6}
|
||
$platform-web={{ display: "inline-flex", flexDirection: "row" }}
|
||
>
|
||
{t('Continue with {plan} →', { plan: m.quote.plan })}
|
||
</Text>
|
||
) : null}
|
||
|
||
<Text
|
||
render={<Link href="/contact-sales" />}
|
||
py={10}
|
||
px={18}
|
||
rounded={9999}
|
||
borderWidth={1}
|
||
borderColor="var(--white-20)"
|
||
color="var(--foreground)"
|
||
fontWeight="500"
|
||
fontSize="$2"
|
||
textDecorationLine="none"
|
||
items="center"
|
||
gap={6}
|
||
$platform-web={{ display: "inline-flex", flexDirection: "row" }}
|
||
>
|
||
{t('Talk to Sales Team')}
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
) : null}
|
||
</Box>
|
||
</Box>
|
||
) : (
|
||
/* User message */
|
||
<Box
|
||
maxW="85%"
|
||
bg="var(--border)"
|
||
rounded={16}
|
||
py={12}
|
||
px={18}
|
||
borderWidth={1}
|
||
borderColor="var(--white-08)"
|
||
$platform-web={{ lineHeight: "1.5", color: "var(--foreground)", fontSize: "var(--text-lg)" }}
|
||
>
|
||
{m.content}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
))}
|
||
|
||
{isThinking ? (
|
||
<Box
|
||
display="flex"
|
||
gap={12}
|
||
items="center"
|
||
flexDirection="row"
|
||
$platform-web={{ color: "var(--muted-foreground)", fontSize: "var(--text-sm)" }}
|
||
>
|
||
<Box
|
||
width={32}
|
||
height={32}
|
||
rounded={9999}
|
||
bg="var(--white-06)"
|
||
display="flex"
|
||
items="center"
|
||
justify="center"
|
||
flexDirection="row"
|
||
>
|
||
<Sparkles size={16} />
|
||
</Box>
|
||
<span>{t('Finding the best plan for your team...')}</span>
|
||
</Box>
|
||
) : null}
|
||
|
||
<View ref={messagesEndRef} />
|
||
</Box>
|
||
)}
|
||
</View>
|
||
|
||
{/* Fixed Bottom Input Bar */}
|
||
<Box
|
||
position="fixed"
|
||
b={0}
|
||
l={0}
|
||
r={0}
|
||
z="var(--z-dock)"
|
||
pt={16}
|
||
px={20}
|
||
pb={24}
|
||
display="flex"
|
||
justify="center"
|
||
flexDirection="row"
|
||
$platform-web={{ backgroundImage: "linear-gradient(to top, var(--surface-card) 70%, color-mix(in srgb, var(--background) 0%, transparent) 100%)" }}
|
||
>
|
||
<View
|
||
render={<form onSubmit={handleSubmit} />}
|
||
width="100%"
|
||
maxW={680}
|
||
position="relative"
|
||
display="flex"
|
||
items="center"
|
||
flexDirection="row"
|
||
>
|
||
<View
|
||
render={<input ref={inputRef} type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder={isChatting ? t('Ask the Buying Specialist a question...') : t('Press enter to get started')} />}
|
||
width="100%"
|
||
bg="var(--white-06)"
|
||
borderWidth={1}
|
||
borderColor="var(--white-10)"
|
||
focusStyle={{ bg: 'var(--white-08)', borderColor: 'var(--white-30)' }}
|
||
rounded={9999}
|
||
pt={16}
|
||
pr={54}
|
||
pb={16}
|
||
pl={22}
|
||
$platform-web={{ outline: "none", boxSizing: "border-box", transition: "border-color 0.15s ease, background-color 0.15s ease", fontSize: "var(--text-lg)", color: "var(--foreground)", display: "inline-block" }}
|
||
/>
|
||
|
||
<View
|
||
render={<button type="submit" aria-label={t('Send message')} />}
|
||
position="absolute"
|
||
r={8}
|
||
width={38}
|
||
height={38}
|
||
rounded={9999}
|
||
bg="var(--foreground)"
|
||
borderWidth={0}
|
||
cursor="pointer"
|
||
display="flex"
|
||
items="center"
|
||
justify="center"
|
||
flexDirection="row"
|
||
hoverStyle={{ opacity: 0.85 }}
|
||
$platform-web={{ transition: "transform 0.15s ease, opacity 0.15s ease", color: "var(--background)" }}
|
||
>
|
||
<ArrowUp size={18} strokeWidth={2.5} />
|
||
</View>
|
||
</View>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
/** Generates specialized domain responses based on user queries and numbers. */
|
||
function generateAssistantResponse(prompt: string, userName: string, t: I18n['t']): Message {
|
||
const p = prompt.toLowerCase()
|
||
const id = String(Date.now())
|
||
|
||
// Look for team numbers in prompt
|
||
const matchNum = p.match(/\b(\d+)\s*(people|developers?|engineers?|members?|seats?|devs?|users?)?\b/)
|
||
const num = matchNum ? parseInt(matchNum[1], 10) : null
|
||
|
||
// 1. Explicit Enterprise or Compliance inquiry
|
||
if (p.includes('enterprise') || p.includes('vpc') || p.includes('hipaa') || p.includes('dedicated') || p.includes('cluster') || p.includes('soc 2') || (num && num > 50)) {
|
||
return {
|
||
id,
|
||
role: 'assistant',
|
||
content: [
|
||
t('For organizations deploying at scale, **Hanzo Enterprise** is the tailored choice.'),
|
||
'',
|
||
t('Here is what is included:'),
|
||
`- ${t('**Dedicated Infrastructure**: Private GPU clusters, custom fine-tuned weights, and unlimited concurrency.')}`,
|
||
`- ${t('**Deployment Options**: Host in your own cloud VPC (AWS, GCP, Azure), on-premise servers, or air-gapped offline environments.')}`,
|
||
`- ${t('**Security & Governance**: Centralized SAML SSO, SCIM provisioning, granular RBAC, and immutable audit logs.')}`,
|
||
`- ${t('**Contracted SLAs**: 99.99% uptime guarantees and 24/7 dedicated engineering support.')}`,
|
||
'',
|
||
t('Would you like me to connect you with our solutions engineering team to review architecture and prepare a custom proposal?'),
|
||
].join('\n'),
|
||
quote: {
|
||
plan: 'Enterprise',
|
||
monthlyTotal: 0,
|
||
annualTotal: 0,
|
||
savings: 0,
|
||
contactSales: true,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 2. Specific Team seat inquiry or team count detected
|
||
if (num && num > 1) {
|
||
const standardAnnual = num * 20 * 12
|
||
const standardMonthly = num * 25
|
||
const mixedStandard = Math.max(1, Math.round(num * 0.7))
|
||
const mixedPremium = num - mixedStandard
|
||
const mixedAnnual = (mixedStandard * 20 + mixedPremium * 100) * 12
|
||
|
||
return {
|
||
id,
|
||
role: 'assistant',
|
||
content: [
|
||
t('For a team of **{n} members**, the **Hanzo Team** plan provides the best balance of pooled usage, shared workspaces, and team controls.', { n: num }),
|
||
'',
|
||
t('You have two seat tiers that you can mix on one single bill:'),
|
||
`- ${t('**Standard Seat**: **$20/seat/mo** billed annually ($240 upfront) or $25/mo monthly. Ideal for engineers writing code, using Claude Code / Cursor, and running everyday agentic workflows.')}`,
|
||
`- ${t('**Premium Seat**: **$100/seat/mo** billed annually ($1,200 upfront) or $125/mo monthly. Provides **5× more usage**, higher rate limits, and priority inference across every model.')}`,
|
||
'',
|
||
`### ${t('Your Pricing Options for {n} Seats:', { n: num })}`,
|
||
`- ${t('**All Standard ({n} seats)**: **${monthly}/mo** billed annually (${annual}/year), saving ${saving} vs monthly.', { n: num, monthly: num * 20, annual: standardAnnual, saving: (standardMonthly * 12) - standardAnnual })}`,
|
||
`- ${t('**Mixed Setup ({standard} Standard + {premium} Premium)**: **${monthly}/mo** billed annually (${annual}/year).', { standard: mixedStandard, premium: mixedPremium, monthly: Math.round(mixedAnnual / 12), annual: mixedAnnual })}`,
|
||
'',
|
||
t('Both setups include unified billing, team workspaces, SSO, and shared automations.'),
|
||
].join('\n'),
|
||
quote: {
|
||
plan: 'Hanzo Team',
|
||
seats: num,
|
||
standardSeats: num,
|
||
monthlyTotal: standardMonthly,
|
||
annualTotal: standardAnnual,
|
||
savings: 20,
|
||
checkoutUrl: `/pay/cart?plan=team&interval=year&quantity=${num}`,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 3. Difference between Standard and Premium seats
|
||
if (p.includes('standard') || p.includes('premium') || p.includes('difference') || p.includes('seat')) {
|
||
return {
|
||
id,
|
||
role: 'assistant',
|
||
content: [
|
||
t("Here is the exact breakdown between Hanzo's two seat tiers:"),
|
||
'',
|
||
`### ${t('1. Standard Seat ($20/mo annual / $25 monthly)')}`,
|
||
`- ${t('**Everything in Hanzo Pro**, plus shared company workspaces.')}`,
|
||
`- ${t('Access to every model (Enso, Zen, Kai, Sonnet, Opus, GPT-5).')}`,
|
||
`- ${t('Hanzo Dev and full CLI tooling (Claude Code, Cursor, Codex acceleration).')}`,
|
||
`- ${t('Pooled limits and unified billing across your team.')}`,
|
||
'',
|
||
`### ${t('2. Premium Seat ($100/mo annual / $125 monthly)')}`,
|
||
`- ${t('**5× higher usage allowances** and 5× higher request limits.')}`,
|
||
`- ${t('**Priority inference** during peak frontier model congestion.')}`,
|
||
`- ${t('Early access to unreleased models and experimental agentic harnesses.')}`,
|
||
'',
|
||
t('Organizations can **freely mix** Standard and Premium seats on a single team account. For instance, in a 10-person engineering team, you can assign 8 Standard seats and 2 Premium seats for lead researchers.'),
|
||
].join('\n'),
|
||
}
|
||
}
|
||
|
||
// 4. Comparison between Team and Enterprise
|
||
if (p.includes('compare') || p.includes('vs')) {
|
||
return {
|
||
id,
|
||
role: 'assistant',
|
||
content: [
|
||
t('Here is a side-by-side comparison between **Team** and **Enterprise**:'),
|
||
'',
|
||
`- ${t('**Hanzo Team** ($20–$100/seat/mo):')}`,
|
||
` - ${t('Self-serve signup starting at 2 seats.')}`,
|
||
` - ${t('Mix Standard ($20) and Premium ($100) seats.')}`,
|
||
` - ${t('Multi-user workspace, shared chat, meetings, and shared storage.')}`,
|
||
` - ${t('Centralized credit card or invoicing.')}`,
|
||
'',
|
||
`- ${t('**Hanzo Enterprise** (Custom pricing):')}`,
|
||
` - ${t('For organizations with strict compliance, security, or massive scale requirements.')}`,
|
||
` - ${t('Custom VPC deployment (AWS, GCP, Azure, or on-premises).')}`,
|
||
` - ${t('Dedicated GPU clusters with guaranteed inference latency SLAs.')}`,
|
||
` - ${t('Enterprise SAML SSO, SCIM, and contracted 24/7 technical support.')}`,
|
||
'',
|
||
t('How many team members are you planning to onboard?'),
|
||
].join('\n'),
|
||
}
|
||
}
|
||
|
||
// 5. Default welcoming response
|
||
return {
|
||
id,
|
||
role: 'assistant',
|
||
content: [
|
||
t('Great to meet you, {name}! I can help you configure the right setup for your team.', { name: userName }),
|
||
'',
|
||
t('To give you the exact numbers and plan recommendation:'),
|
||
`1. ${t('**How many people** will be using Hanzo?')}`,
|
||
`2. ${t('**What are your primary use cases** (e.g. coding agents, Claude Code acceleration, model routing, or custom APIs)?')}`,
|
||
`3. ${t('Do you have specific **compliance requirements** like custom VPC or SSO?')}`,
|
||
].join('\n'),
|
||
}
|
||
}
|