mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The page drew a heading (Start your company with one sentence) and, under it, the composer's own (Say what to build). The composer now stands under the page's title and line on the fold, filling the window, and the dock still takes over once it scrolls away.
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 } from './band'
|
|
import { Fold } from '@/components/marketing/fold'
|
|
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)'
|
|
|
|
/**
|
|
* /start's first screen: the page's title and line over the starter chips and
|
|
* the composer, filling the window under the bar (`components/marketing/fold`),
|
|
* and the dock that stands in for the composer once it scrolls away.
|
|
*/
|
|
export function FooterHeroComposer({ title, lede }: { title: string; lede: string }) {
|
|
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 (
|
|
<>
|
|
<Fold label="Start" title={title} lede={lede}>
|
|
<YStack width="100%" maxW={840} mx="auto">
|
|
<YStack 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>
|
|
</Fold>
|
|
|
|
{/* 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 end 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={{ paddingInlineEnd: '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>
|
|
</>
|
|
)
|
|
}
|