mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, type FormEvent } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useAnalytics } from '@hanzo/event/react'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { EVENTS } from '@hanzo/event'
|
|
import { useDictation, useVoice, Voice } from '@hanzo/voice'
|
|
import { ArrowUp, Mic, BookOpen } from 'lucide-react'
|
|
import { Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { Composer, Control, Field } from '@hanzo/composer'
|
|
import { useEar, useSays } from '@/components/shell/ear'
|
|
import { PILL } from '@/components/shell/pill'
|
|
import { Action, Actions, Band, Head } from './band'
|
|
import { signUp, goToChat } from './nav-data'
|
|
import { dock, DOCK_FADE, DOCK_FOOT, DOCK_TOP } from '@/components/shell/dock'
|
|
import { CHIPS, PROMPTS } from './prompts'
|
|
import { handoff } from '@hanzo/app/lib/destination'
|
|
|
|
|
|
/** The field's rung on the type ramp: the ask reads a size up. */
|
|
const TEXT_XL = 'var(--text-xl)' as never
|
|
|
|
/** A starter chip's label size: the ramp's own rung. */
|
|
const TEXT_SM = 'var(--text-sm)' as never
|
|
|
|
/**
|
|
* The pill: the chrome's glass (shell/pill's `PILL`) with a hairline, so the
|
|
* composer reads as a control over the page and over the fold, in either theme.
|
|
*/
|
|
const EDGE = { borderWidth: 1, borderColor: 'var(--border)' } as const
|
|
|
|
/** The docked pill's drop and hairline, under the page it floats over. */
|
|
const DROP = '0 8px 32px color-mix(in srgb, var(--pure-black) 45%, transparent), 0 0 0 1px var(--white-08)'
|
|
|
|
export function FooterHeroComposer() {
|
|
const { t } = useI18n()
|
|
const says = useSays()
|
|
const router = useRouter()
|
|
const [value, setValue] = useState('')
|
|
const [promptIndex, setPromptIndex] = useState(0)
|
|
const [isFixed, setIsFixed] = useState(false)
|
|
const [isDockedAtBottom, setIsDockedAtBottom] = useState(false)
|
|
/** The in-page composer the dock stands in for. */
|
|
const composer = useRef<HTMLFormElement | null>(null)
|
|
const analytics = useAnalytics()
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setPromptIndex((prev) => (prev + 1) % PROMPTS.length)
|
|
}, 3500)
|
|
return () => clearInterval(timer)
|
|
}, [])
|
|
|
|
// THE DOCK STANDS IN FOR THE COMPOSER, so it is the composer's own visibility
|
|
// that decides — asked of the element, never guessed from a distance.
|
|
//
|
|
// It was two pixel thresholds: show past 320px of scroll, hide within 320px of
|
|
// the document's bottom. Neither number knows where the composer is, and the
|
|
// footer's height is what sits between them — so the same rule showed BOTH at
|
|
// 1440 and 768 and NEITHER at 1024, 870 and 390, where the footer is taller
|
|
// than the guess. Measured on the shipped export at those five widths.
|
|
//
|
|
// An observer on the real element cannot drift: the dock appears exactly when
|
|
// the thing it substitutes for is off screen.
|
|
useEffect(() => {
|
|
const here = composer.current
|
|
if (!here) return
|
|
|
|
// Two reasons to step aside, each asked of its own element: the composer
|
|
// the dock stands in for is on screen, or something marked `data-clear` —
|
|
// the fold's drawn product — reaches into the band the dock covers.
|
|
const aside = new Set<Element>()
|
|
const note = (entries: IntersectionObserverEntry[]) => {
|
|
for (const e of entries) {
|
|
if (e.isIntersecting) aside.add(e.target)
|
|
else aside.delete(e.target)
|
|
}
|
|
setIsDockedAtBottom(aside.size > 0)
|
|
}
|
|
// A composer half on screen is still reachable, so the dock stays away
|
|
// until it has genuinely left.
|
|
const watch = new IntersectionObserver(note, { threshold: 0 })
|
|
watch.observe(here)
|
|
// The band is the screen's bottom 15%: the dock and the fade above it.
|
|
const band = new IntersectionObserver(note, { rootMargin: '-85% 0px 0px 0px', threshold: 0 })
|
|
document.querySelectorAll('[data-clear]').forEach((el) => band.observe(el))
|
|
|
|
// The dock is pinned to the bottom of the screen from the first pixel, and
|
|
// steps aside only while one of the two is true.
|
|
setIsFixed(true)
|
|
|
|
return () => {
|
|
watch.disconnect()
|
|
band.disconnect()
|
|
}
|
|
}, [])
|
|
|
|
const voice = useVoice({ speech: useEar(), ...useDictation(value, setValue) })
|
|
const send = (e: { preventDefault: () => void }) => {
|
|
e.preventDefault()
|
|
submit()
|
|
}
|
|
const currentPlaceholder = t(PROMPTS[promptIndex])
|
|
const shown = isFixed && !isDockedAtBottom
|
|
|
|
const submit = (e?: FormEvent, customPrompt?: string) => {
|
|
if (e?.preventDefault) e.preventDefault()
|
|
const promptToSend = (customPrompt || value.trim() || currentPlaceholder).trim()
|
|
try {
|
|
analytics?.capture?.(EVENTS.CHAT_STARTED, {
|
|
source: 'footer_composer',
|
|
hasPrompt: promptToSend.length > 0,
|
|
})
|
|
} catch {}
|
|
const target = promptToSend ? `/chat?q=${encodeURIComponent(promptToSend)}` : '/chat'
|
|
// A host that cannot sign in asks on hanzo.ai instead (lib/host.ts `guest`).
|
|
if (handoff(target)) return
|
|
if (router?.push) {
|
|
router.push(target)
|
|
} else {
|
|
goToChat(promptToSend)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Band id="get-started">
|
|
<YStack width="100%" maxW={840} mx="auto">
|
|
<Head
|
|
eyebrow={t('Start')}
|
|
title={t('Say what to build.')}
|
|
lede={t('One sentence starts an app, an agent, or a whole company.')}
|
|
/>
|
|
|
|
<YStack mt={28} gap={16}>
|
|
{/* Quick Starters Chips */}
|
|
<XStack flexWrap="wrap" justify="center" gap={8}>
|
|
{CHIPS.map((chip) => (
|
|
<View
|
|
key={chip.label}
|
|
render="button"
|
|
onClick={() => submit(undefined, chip.prompt)}
|
|
px={12}
|
|
py={6}
|
|
rounded={9999}
|
|
bg="var(--glass)"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
cursor="pointer"
|
|
hoverStyle={{ bg: '$raised', borderColor: 'var(--border-selected)' }}
|
|
transition="quickest"
|
|
>
|
|
<Text fontSize={TEXT_SM} fontWeight="500" color="var(--text-secondary)">
|
|
{t(chip.label)}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</XStack>
|
|
|
|
{/* Composer Input Box */}
|
|
<Composer
|
|
render="form"
|
|
ref={composer as never}
|
|
{...{ onSubmit: (e: FormEvent) => submit(e) }}
|
|
control={38}
|
|
width="100%"
|
|
maxW={576}
|
|
mx="auto"
|
|
>
|
|
<XStack {...PILL} {...EDGE}>
|
|
<Field
|
|
value={value}
|
|
onChangeText={setValue}
|
|
onKeyDown={(e: { key: string; preventDefault: () => void }) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
submit()
|
|
}
|
|
}}
|
|
placeholder={currentPlaceholder}
|
|
aria-label={t('Ask anything or describe what to build')}
|
|
fontSize={TEXT_XL}
|
|
px={12}
|
|
/>
|
|
|
|
<Control asChild>
|
|
<Voice voice={voice} says={says}>
|
|
{(state) => <Mic size={15} fill={state === 'idle' ? 'none' : 'currentColor'} />}
|
|
</Voice>
|
|
</Control>
|
|
|
|
<Control type="submit" aria-label={t('Send')} fill onClick={send}>
|
|
<ArrowUp size={15} strokeWidth={2.5} />
|
|
</Control>
|
|
</XStack>
|
|
</Composer>
|
|
|
|
{/* The ask closes the way the fold opened: Get started is the
|
|
sign-up, the one door Try Hanzo also opens. Chat is not offered
|
|
here: the composer directly above IS the chat. The setup prompt
|
|
for a coding agent is in the fold and the agent row. */}
|
|
<Actions top={12}>
|
|
<Action href={signUp()} filled>
|
|
{t('Get started')}
|
|
</Action>
|
|
<Action href="https://docs.hanzo.ai">
|
|
<BookOpen size={15} color="currentColor" aria-hidden />
|
|
{t('Docs')}
|
|
</Action>
|
|
</Actions>
|
|
</YStack>
|
|
</YStack>
|
|
</Band>
|
|
|
|
{/* THE COMPOSER'S BAND, fixed at the bottom edge. It sits on the page
|
|
gutter, so the one control at the bottom edge is inset by the same rule
|
|
as the bar at the top; 3px above and the foot's inset below wrap the
|
|
pill closely and still clear the focus ring. No band material: the pill
|
|
carries its own glass. On a phone the pill is nearly the window's width,
|
|
so it clears the help corner at the start edge (components/shell/help.tsx:
|
|
a 32px mark 10px in). It stands on design's dock rung, under the cookie
|
|
banner, which stays answerable over it. */}
|
|
<YStack
|
|
ref={(el: unknown) => dock(el instanceof HTMLElement ? el : null)}
|
|
data-dock
|
|
position="fixed"
|
|
l={0}
|
|
r={0}
|
|
b={0}
|
|
z="var(--z-dock)"
|
|
paddingInline="var(--page-gutter)"
|
|
pt={DOCK_TOP}
|
|
pb={DOCK_FOOT}
|
|
$max-md={{ paddingInlineStart: 'calc(var(--page-gutter) + 32px)' }}
|
|
opacity={shown ? 1 : 0}
|
|
y={shown ? 0 : 16}
|
|
pointerEvents={shown ? 'auto' : 'none'}
|
|
transition="quicker"
|
|
>
|
|
{/* WHAT THE BAND CARRIES INSTEAD OF A GROUND. The composer rides every
|
|
scroll position, so a line of type passes under it. A fade to the
|
|
page's own ground is not a seam anywhere: invisible over empty page,
|
|
it dissolves the line as it passes. Behind the pill, deaf to the pointer. */}
|
|
<View
|
|
position="absolute"
|
|
t={`calc(-1 * ${DOCK_FADE})`}
|
|
r={0}
|
|
b={0}
|
|
l={0}
|
|
z={-1}
|
|
pointerEvents="none"
|
|
backgroundImage="linear-gradient(to top, var(--background) 42%, transparent)"
|
|
/>
|
|
<Composer
|
|
render="form"
|
|
{...{ onSubmit: (e: FormEvent) => submit(e) }}
|
|
control={38}
|
|
width="100%"
|
|
maxW={576}
|
|
mx="auto"
|
|
>
|
|
{/* Floating over the page, so the pill carries its own drop. */}
|
|
<XStack
|
|
{...PILL}
|
|
{...EDGE}
|
|
boxShadow={`${DROP}, 0 0 0 2px transparent`}
|
|
focusWithinStyle={{ boxShadow: `${DROP}, 0 0 0 2px var(--ring)` }}
|
|
>
|
|
<Field
|
|
value={value}
|
|
onChangeText={setValue}
|
|
placeholder={currentPlaceholder}
|
|
aria-label={t('Ask anything')}
|
|
fontSize={TEXT_XL}
|
|
px={12}
|
|
/>
|
|
|
|
<Control asChild>
|
|
<Voice voice={voice} says={says}>
|
|
{(state) => <Mic size={15} fill={state === 'idle' ? 'none' : 'currentColor'} />}
|
|
</Voice>
|
|
</Control>
|
|
|
|
<Control type="submit" aria-label={t('Send')} fill onClick={send}>
|
|
<ArrowUp size={15} strokeWidth={2.5} />
|
|
</Control>
|
|
</XStack>
|
|
</Composer>
|
|
</YStack>
|
|
</>
|
|
)
|
|
}
|