Files
hanzo.ai/components/beta/portal.tsx
zeekay 4fd349aa06 beta: the portal says less, and opting in and sharing are one press
Prose that carried no decision or fact is gone: the hero, the questions, the
gate and every step are a heading and at most a line. What scores is said once
(the leaderboard step), and every claim the cloud does not keep is out.

Step 1 is one share card (the member's link, Copy, the device's share sheet on
phones, seven channels as icons, Customize behind a text button) and a quiet
invite-by-email card. Step 3 is one searched, grouped list with one control a
row: On when `opens` says so, Get access when it does not, Requested at once
after the press. Stars and follows moved under the leaderboard as Earn more.

The 1,800-line dashboard is a shell over share, rank, betas and alpha; what
they read is one hook (standing.ts); the questions are data (program.ts).
Dead access, card, hint and referral components are deleted. Every press is
44px, every input too, and a gate walks each screen at 390px.

scripts/brands and scripts/prebuild are TypeScript now; the three marks the
share row draws ship in a JSON only /beta loads. /beta's title and card say
what the page says.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:29:13 -07:00

789 lines
30 KiB
TypeScript

'use client'
/**
* /beta — apply to the Hanzo beta, then the member's portal.
*
* A stranger picks a program and answers (a third of the questions is the
* floor, and past it the plan is one tap away), saves the application with an
* account, picks a plan once, and has the dashboard. One component for both
* frames: /beta draws it in the site's shell, and the app's Betas mode draws it
* as its pane (`framed`: the pane's own fill, padding and scroll, with no page
* glow or window height of its own).
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ArrowLeft, ArrowRight, Check, FlaskConical, Handshake, Rocket } from 'lucide-react'
import { Input, Text, Textarea, View, XStack, YStack } from '@hanzo/ui'
import { Action, Display, Lede, Title } from '@hanzo/ui/marketing'
import { BOOKING } from '@/components/home/nav-data'
import { useI18n } from '@hanzo/i18n/react'
import { brought } from '@/lib/campaign'
import { plans as plansStep } from '@/lib/checkout'
import { span } from '@/lib/coupon'
import { useQuote } from '@/hooks/useQuote'
import { mirror, track, visit } from '@hanzo/app/lib/tags'
import { api } from '@hanzo/app/lib/api'
import { Experimental } from '@hanzo/app/experimental'
import { named, scope } from '@hanzo/app/lib/session'
import { useIam } from '@hanzo/iam/react'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { setBack, setBetaRunning } from '@/components/shell/mode'
import { Glow } from '@/components/shell/focus'
import { ROOF, under } from '@/components/shell/dock'
import { Card, Quiet } from './card'
import { Feedback } from './feedback'
import { BetaDashboard } from './dashboard'
import { ABOUT, PROGRAMS, type Ask, type Program } from './program'
const ICONS: Record<Program, ReactNode> = {
beta: <Rocket size={22} color="var(--foreground)" aria-hidden />,
research: <FlaskConical size={22} color="var(--foreground)" aria-hidden />,
partner: <Handshake size={22} color="var(--foreground)" aria-hidden />,
}
const COUPON = '50OFFBETA'
/** The questionnaire is three short steps, so its progress is three bars: each fills as its third of the questions is passed. */
const STEPS = 3
const PLAN = 'dev'
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const SAVED = 'hanzo.beta'
/** Mirrored under `hz_` prefix so `own(subject())` does not wipe local beta answers when signing in. */
const SAVED_PERSIST = 'hz_beta_saved'
type Answer = string | string[]
type Saved = { program: Program; step: number; got: Record<string, Answer>; filed?: boolean; chose?: 'free' | 'pro' }
const said = (v: Answer | undefined) => (Array.isArray(v) ? v.join(', ') : (v ?? '').trim())
const answered = (a: Ask, v: Answer | undefined) => (a.kind === 'many' ? Array.isArray(v) && v.length > 0 : said(v) !== '')
function answers(asks: readonly Ask[], got: Record<string, Answer>): { question: string; answer: string }[] {
return asks.filter((a) => !a.to && said(got[a.id])).map((a) => ({ question: a.q, answer: said(got[a.id]) }))
}
function words(asks: readonly Ask[], got: Record<string, Answer>): string {
return asks
.filter((a) => a.kind === 'long' && said(got[a.id]))
.map((a) => said(got[a.id]))
.join('\n\n')
.slice(0, 4000)
}
function load(): Saved | null {
try {
const raw = localStorage.getItem(SAVED) ?? localStorage.getItem(SAVED_PERSIST) ?? 'null'
const s = JSON.parse(raw) as Saved | null
if (!s || !(s.program in PROGRAMS) || typeof s.got !== 'object' || !s.got) return null
return s
} catch {
return null
}
}
function save(s: Saved | null) {
try {
if (s) {
const str = JSON.stringify(s)
localStorage.setItem(SAVED, str)
localStorage.setItem(SAVED_PERSIST, str)
} else {
localStorage.removeItem(SAVED)
localStorage.removeItem(SAVED_PERSIST)
}
} catch {
/* ignore storage errors */
}
}
/**
* The beta portal, one component for both frames: /beta draws it in the site's
* shell for a stranger and in the app's Betas mode for a member (app/beta).
* A stranger picks a program and answers; a member who has filed picks a plan
* once, then has the dashboard.
*
* `framed` is the portal as the app's Betas pane (app/_web.tsx): the pane's own fill, padding
* and scroll, with no page glow or window height of its own.
*/
/** The questionnaire, the plan and the dashboard, in the order a member meets them. */
export function Portal({ framed = false }: { framed?: boolean } = {}) {
const [program, setProgram] = useState<Program | null>(null)
const [step, setStep] = useState(0)
const [got, setGot] = useState<Record<string, Answer>>({})
const [filed, setFiled] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [paid, setPaid] = useState(false)
const [busy, setBusy] = useState(false)
const [trap, setTrap] = useState('')
const [feedback, setFeedback] = useState(false)
// Filed in this visit, and which plan was picked after it: the plan step shows once.
const [justFiled, setJustFiled] = useState(false)
const [chose, setChose] = useState<'free' | 'pro' | undefined>(undefined)
const { t } = useI18n()
const hydrated = useHydrated()
const { user, isAuthenticated, isLoading } = useIam()
const token = hydrated && isAuthenticated ? named() : null
const mail = hydrated && isAuthenticated ? user?.email || token?.email || '' : ''
const who = String(user?.displayName || user?.name || token?.name || '')
const quote = useQuote(COUPON)
const deal = quote && 'deals' in quote ? (quote.deals[PLAN] ?? Object.values(quote.deals)[0]) : undefined
// Restore previous application or handle checkout return
useEffect(() => {
const q = new URLSearchParams(window.location.search)
const kept = load()
if (q.get('checkout') === 'success') {
track('beta_paid', { subscription: q.get('subscription') ?? 'none' })
if (kept) {
setProgram(kept.program)
setGot(kept.got)
setFiled(Boolean(kept.filed))
}
setPaid(true)
setSubmitted(true)
return
}
if (q.get('feedback') === '1') setFeedback(true)
const p = q.get('program')
const namedProg = p === 'beta' || p === 'research' || p === 'partner' ? p : null
if (kept && (!namedProg || namedProg === kept.program)) {
setProgram(kept.program)
setStep(kept.step || 0)
setGot(kept.got || {})
setFiled(Boolean(kept.filed))
setChose(kept.chose)
if (kept.filed) setSubmitted(true)
} else if (namedProg) {
setProgram(namedProg)
}
track('beta_viewed', { initial_program: namedProg ?? kept?.program ?? 'none', resumed: Boolean(kept) })
}, [])
// Check if authenticated user already has an active beta record, or sync locally saved application on sign-in
useEffect(() => {
if (!hydrated || !isAuthenticated) return
let live = true
fetch(`${api()}/v1/marketing/beta`, { headers: scope() })
.then(async (r) => {
const kept = load()
if (!r.ok) {
if (live && kept?.filed && Object.keys(kept.got || {}).length > 0) {
void file()
}
return
}
const body = (await r.json()) as { program?: string; answers?: unknown[] }
if (live && body.program && (body.program in PROGRAMS)) {
setProgram(body.program as Program)
setFiled(true)
setSubmitted(true)
} else if (live && kept?.filed && Object.keys(kept.got || {}).length > 0) {
void file()
}
})
.catch(() => {})
return () => {
live = false
}
}, [hydrated, isAuthenticated])
// Sync state to localStorage
useEffect(() => {
if (hydrated && program) {
save({ program, step, got, filed: filed || submitted, chose })
}
}, [hydrated, program, step, got, filed, submitted, chose])
// Determine which questions to present (account creation/sign-in handles identity at the end)
const asks = useMemo(() => {
if (!program) return []
return [...PROGRAMS[program].asks, ...ABOUT]
}, [program])
const ask = program && !submitted && !paid ? asks[step] : undefined
const last = asks.length > 0 && step === asks.length - 1
const ready = ask ? answered(ask, got[ask.id]) : false
// A third of the questions must be answered to apply. Past it the plan is one tap away.
const need = Math.ceil(asks.length / 3)
const count = asks.filter((a) => answered(a, got[a.id])).length
const enough = count >= need
// A skip is allowed while the questions still ahead can make up the third.
const canSkip = count + (asks.length - step - 1) >= need
const stage = asks.length > 0 ? Math.min(STEPS - 1, Math.floor((step * STEPS) / asks.length)) : 0
// Require account creation / login before entering the Beta Dashboard
const hasAccount = Boolean(hydrated && isAuthenticated && scope().Authorization)
const needsAccountGate = (submitted || filed || paid) && !hasAccount && !feedback
const isDashboard = (submitted || filed || paid) && hasAccount && !feedback
const asking = Boolean(program) && !isDashboard && !needsAccountGate && !feedback
// Right after filing, a member picks Pro with the beta code or stays on Free, then has the dashboard.
const planning = isDashboard && justFiled && !paid && !chose
useLayoutEffect(() => {
setBetaRunning(asking)
return () => setBetaRunning(false)
}, [asking])
const stepBack = () => {
if (feedback) return setFeedback(false)
if (isDashboard) {
setSubmitted(false)
setStep(0)
return
}
if (step > 0) return setStep((s) => s - 1)
setProgram(null)
save(null)
}
const stepRef = useRef(stepBack)
useEffect(() => {
stepRef.current = stepBack
})
useEffect(() => {
if (!asking) return
setBack(
<Pill onPress={() => stepRef.current()}>
<ArrowLeft size={14} aria-hidden data-i18n-mirror />
{t('Back')}
</Pill>,
)
return () => setBack(null)
}, [asking, t])
// Focus input field when question advances
useEffect(() => {
const id = feedback ? 'beta-feedback-note' : 'beta-answer'
const timer = window.setTimeout(() => (document.getElementById(id) as HTMLElement | null)?.focus(), 60)
return () => window.clearTimeout(timer)
}, [program, step, feedback, isDashboard])
const selectProgram = (id: Program, fromKey = false) => {
track('beta_track_selected', { program: id, program_name: PROGRAMS[id].name, from_key: fromKey })
setProgram(id)
setStep(0)
}
const put = (id: string, v: Answer) => setGot((g) => ({ ...g, [id]: v }))
const pick = (a: Ask, option: string) => {
track('beta_option_picked', { program, question_id: a.id, option })
if (a.kind === 'one') {
put(a.id, option)
window.setTimeout(advance, 260)
return
}
const now = Array.isArray(got[a.id]) ? (got[a.id] as string[]) : []
put(a.id, now.includes(option) ? now.filter((o) => o !== option) : [...now, option])
}
/** File the application lead in the CRM and beta records. */
async function file(): Promise<void> {
if (!program || trap) return
const seen = visit()
const campaign = brought(window.location.search)
const text = (k: string) => (typeof got[k] === 'string' ? (got[k] as string).trim() : '')
const targetEmail = mail
let res: { lead?: string; offer?: string } = {}
try {
const r = await fetch(`${api()}/v1/marketing/leads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...scope() },
body: JSON.stringify({
email: targetEmail,
name: who,
company: text('company'),
role: text('role'),
size: text('size'),
need: words(asks, got),
answers: answers(asks, got),
program,
source: 'hanzo.ai/beta',
visit: seen,
...(campaign.length ? { campaign: Object.fromEntries(campaign.filter(([k]) => k.startsWith('utm_'))) } : {}),
}),
})
if (r.ok) res = (await r.json()) as { lead?: string; offer?: string }
} catch {
res = {}
}
// Save answers onto the beta record if authenticated or targetEmail present
if (targetEmail) {
try {
const mineAsks = asks.filter((a) => said(got[a.id]))
const now = mineAsks.map((a) => ({
id: a.id,
question: a.q,
answer: Array.isArray(got[a.id]) ? (got[a.id] as string[]) : [said(got[a.id])],
}))
await fetch(`${api()}/v1/marketing/beta`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...scope() },
body: JSON.stringify({ program, answers: now }),
})
} catch {
/* lead already saved */
}
}
setFiled(true)
save({ program, step: asks.length - 1, got, filed: true })
track('beta_submitted', { program })
mirror('beta_applied', { program, offer: res.offer ?? 'none' }, res.lead ?? 'lead_local', seen.tags as string)
}
/** Advance to next question or submit and open the dashboard. */
async function advance() {
if (!last) return setStep((s) => s + 1)
if (!enough) return
setBusy(true)
track('beta_step_completed', {
program,
step: step + 1,
total_steps: asks.length,
question_id: ask?.id,
answer: ask ? said(got[ask.id]) : '',
})
await file()
setBusy(false)
setJustFiled(true)
setSubmitted(true)
}
/** Save application and proceed to the Plans Chooser (/pay/cart) with 50OFFBETA and Continue with Free. */
async function continueWithPlan() {
if (!program || !enough) return
setBusy(true)
save({ program, step, got, filed: true })
track('beta_continue_with_plan', { program, step: step + 1, total_steps: asks.length })
await file()
setBusy(false)
const target = plansStep({
coupon: COUPON,
campaign: brought(window.location.search),
returnUrl: `${window.location.origin}/chat`,
})
window.location.assign(target)
}
const next = () => {
if (!ask) return
advance()
}
// Keyboard navigation
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (isDashboard) return
if (feedback) {
if (e.key === 'Enter' && !e.shiftKey && e.target instanceof HTMLTextAreaElement) {
e.preventDefault()
;(document.getElementById('beta-feedback-send') as HTMLButtonElement | null)?.click()
}
return
}
const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
if (!program) {
const i = LETTERS.indexOf(e.key.toUpperCase())
const ids = Object.keys(PROGRAMS) as Program[]
if (!typing && e.key.length === 1 && i >= 0 && i < ids.length) selectProgram(ids[i], true)
return
}
if (e.key === 'Enter' && !(ask?.kind === 'long' && e.shiftKey)) {
e.preventDefault()
if (ready || (last && enough)) advance()
return
}
if (ask?.options && !typing && e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
const i = LETTERS.indexOf(e.key.toUpperCase())
if (i >= 0 && i < ask.options.length) pick(ask, ask.options[i])
}
}
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
})
const openFeedback = (from: string) => {
track('beta_feedback_opened', { from, program: program ?? 'none' })
setFeedback(true)
}
if ((program || feedback) && !paid && !submitted && (!hydrated || isLoading)) {
return (
<YStack render="section" minH={framed ? 240 : 'calc(100svh - 64px)'} px={framed ? 0 : 'var(--page-gutter)'} pt={framed ? 8 : 40} pb={framed ? 32 : 64} justify="center" items="center">
<Text fontSize="$3" color="var(--muted-foreground)">{t('Loading…')}</Text>
</YStack>
)
}
return (
// Grows to fill the frame and never shrinks below its content: inside the app's Betas pane,
// which has a fixed height and scrolls, a shrinking section clipped everything under the fold.
<YStack
render="section"
aria-label={t('Apply to the Hanzo beta')}
position="relative"
grow={1}
shrink={0}
minW={0}
// On the site, the section is the page: a window tall, in the page's gutter, on the page's glow.
// In the app it is a card's worth of content in a pane that already pads and fills itself.
minH={framed ? undefined : `calc(100svh - 64px + ${ROOF})`}
px={framed ? 0 : 'var(--page-gutter)'}
pb={framed ? 32 : 56}
{...(framed ? { pt: 8 } : under(32))}
justify={isDashboard ? 'flex-start' : 'center'}
overflow={framed ? 'visible' : 'hidden'}
>
{framed ? null : <Glow />}
<YStack position="relative" width="100%" maxW={isDashboard ? 960 : asking || needsAccountGate ? 680 : 980} mx="auto">
{/* The choice for what comes next, above everything else on /beta; nothing is drawn signed out. */}
<YStack mb={16}>
<Experimental />
</YStack>
{needsAccountGate ? (
<Card key="account-gate" gap={20}>
<YStack gap={8}>
<Title render="h1">{t('Save your application.')}</Title>
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Create a free account to keep your place in line.')}
</Text>
</YStack>
<XStack items="center" gap={10} flexWrap="wrap">
<Action href={`/signup?next=${encodeURIComponent('/beta')}`} fill onClick={() => track('beta_gate_signup_clicked', { program: program ?? 'beta' })}>
{t('Create account')}
<ArrowRight size={16} aria-hidden data-i18n-mirror />
</Action>
<Action href={`/login?next=${encodeURIComponent('/beta')}`} onClick={() => track('beta_gate_login_clicked', { program: program ?? 'beta' })}>
{t('Sign in')}
</Action>
<Quiet
render="button"
type="button"
onClick={() => {
setSubmitted(false)
setStep(Math.max(0, asks.length - 1))
}}
>
{t('Edit answers')}
</Quiet>
</XStack>
</Card>
) : planning ? (
<Card key="plan" gap={20}>
<YStack gap={8}>
<Title render="h1">{t('Your application is in. Pick your plan.')}</Title>
<Text fontSize="$3" color="var(--muted-foreground)">
{deal ? t('{percent}% off Pro for {span}. Free keeps your place in line.', { percent: deal.percent, span: span(deal.months) }) : t('Free keeps your place in line.')}
</Text>
</YStack>
<XStack gap={10} flexWrap="wrap">
<Action
id="beta-plan-pro"
render="button"
type="button"
fill
onClick={() => {
track('beta_plan_chosen', { program: program ?? 'beta', plan: 'pro' })
setChose('pro')
window.location.assign(plansStep({ coupon: COUPON, campaign: brought(window.location.search), returnUrl: `${window.location.origin}/beta?checkout=success` }))
}}
>
{t('Continue with Pro')}
<ArrowRight size={16} aria-hidden data-i18n-mirror />
</Action>
<Action
id="beta-plan-free"
render="button"
type="button"
onClick={() => {
track('beta_plan_chosen', { program: program ?? 'beta', plan: 'free' })
setChose('free')
}}
>
{t('Stay on Free')}
</Action>
</XStack>
</Card>
) : isDashboard ? (
<BetaDashboard
email={mail}
name={who}
program={program ?? 'beta'}
paid={paid}
checkoutUrl={plansStep({ coupon: COUPON, campaign: brought(window.location.search), returnUrl: `${window.location.origin}/chat` })}
offer={deal ? t('{percent}% off Pro for {span}', { percent: deal.percent, span: span(deal.months) }) : undefined}
onEditApplication={() => {
setSubmitted(false)
setStep(0)
}}
/>
) : feedback ? (
!isAuthenticated ? (
<Card key="feedback-auth" gap={20}>
<Title render="h1">{t('Sign in to write to the team.')}</Title>
<XStack>
<Action href={`/login?next=${encodeURIComponent('/beta?feedback=1')}`} fill>
{t('Sign in')}
</Action>
</XStack>
</Card>
) : (
<Feedback program={program} email={mail} name={who} />
)
) : !program ? (
<YStack key="start" data-motion="rise" items="center" gap={28}>
<XStack data-slot="beta" items="center" px={14} py={6} rounded={9999} position="relative" overflow="hidden">
<Text fontFamily="$mono" fontSize="$1" fontWeight="600" color="var(--pure-white)">
{t('Hanzo beta')}
</Text>
</XStack>
<YStack items="center" gap={12}>
<Display render="h1" text="center">
{t('Get there first.')}
</Display>
<Lede text="center">{t('Early access to Dev, Studio, Kai, Enso and Zen.')}</Lede>
</YStack>
<View width="100%" display="grid" gap={12} $md={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
{(Object.keys(PROGRAMS) as Program[]).map((id, i) => (
<YStack
key={id}
render={<button type="button" onClick={() => selectProgram(id)} />}
gap={12}
p={24}
rounded="var(--radius-2xl)"
borderWidth={1}
borderColor="var(--border)"
bg="$panel"
hoverStyle={{ borderColor: 'var(--foreground)' }}
$platform-web={{ textAlign: 'start' }}
>
<XStack items="center" justify="space-between">
{ICONS[id]}
<Key>{LETTERS[i]}</Key>
</XStack>
<YStack gap={4}>
<Text fontSize="$5" fontWeight="600" color="var(--foreground)">
{t(PROGRAMS[id].name)}
</Text>
<Text fontSize="$3" color="var(--muted-foreground)">
{t(PROGRAMS[id].gets)}
</Text>
</YStack>
<XStack items="center" gap={6} mt="auto" pt={4}>
<Text fontSize="$3" fontWeight="500" color="var(--foreground)">
{t('Apply')}
</Text>
<ArrowRight size={14} color="var(--foreground)" aria-hidden data-i18n-mirror />
</XStack>
</YStack>
))}
</View>
<XStack gap={20} flexWrap="wrap" justify="center">
<Quiet render="button" type="button" onClick={() => openFeedback('home')}>
{t('Feedback')}
</Quiet>
<Quiet href={BOOKING} target="_blank" rel="noopener noreferrer" onClick={() => track('beta_call_booked', { from: 'landing' })}>
{t('Book a call')}
</Quiet>
</XStack>
</YStack>
) : ask ? (
<YStack key={`${program}-${ask.id}`} data-motion="rise" gap={28}>
<YStack gap={10}>
<XStack justify="space-between">
<Text fontFamily="$mono" fontSize="$2" color="var(--muted-foreground)">
{t(PROGRAMS[program].name)}
</Text>
<Text fontFamily="$mono" fontSize="$2" color="var(--muted-foreground)">
{t('Step {n} of {total}', { n: stage + 1, total: STEPS })}
</Text>
</XStack>
<Progress done={step + (ready ? 1 : 0.5)} total={asks.length} />
</YStack>
<YStack gap={8}>
<Title render="h1">{t(ask.q)}</Title>
{ask.kind === 'many' ? (
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Choose as many as fit.')}
</Text>
) : null}
</YStack>
{ask.options ? (
<YStack gap={10} role={ask.kind === 'one' ? 'radiogroup' : 'group'} aria-label={t(ask.q)}>
{ask.options.map((o, i) => {
const v = got[ask.id]
const on = Array.isArray(v) ? v.includes(o) : v === o
return (
<XStack
key={o}
id={i === 0 ? 'beta-answer' : undefined}
render={<button type="button" role={ask.kind === 'one' ? 'radio' : 'checkbox'} aria-checked={on} onClick={() => pick(ask, o)} />}
items="center"
gap={14}
px={16}
py={14}
minH={52}
rounded={14}
borderWidth={1}
borderColor={on ? 'var(--foreground)' : 'var(--border)'}
bg={on ? 'var(--secondary)' : 'transparent'}
hoverStyle={{ borderColor: 'var(--foreground)' }}
$platform-web={{ textAlign: 'start' }}
>
<Key on={on}>{LETTERS[i]}</Key>
<Text flex={1} fontSize="$4" color="var(--foreground)">
{t(o)}
</Text>
{on ? <Check size={18} color="var(--foreground)" aria-hidden /> : null}
</XStack>
)
})}
</YStack>
) : ask.kind === 'long' ? (
<Textarea id="beta-answer" size="lg" rows={5} rounded={14} bg="var(--muted)" placeholder={t('Type your answer…')} value={(got[ask.id] as string) ?? ''} onChangeText={(v: string) => put(ask.id, v)} />
) : (
<Input
id="beta-answer"
size="lg"
rounded={14}
bg="var(--muted)"
autoComplete={ask.to === 'company' ? 'organization' : ask.to === 'role' ? 'organization-title' : 'off'}
placeholder={t('Type your answer…')}
value={(got[ask.id] as string) ?? ''}
onChange={(e) => put(ask.id, e.target.value)}
/>
)}
<XStack items="center" gap={10} flexWrap="wrap">
{last ? (
<>
<Action id="beta-checkout" render="button" type="button" fill disabled={busy || !enough} onClick={() => void continueWithPlan()}>
{busy ? t('Submitting…') : t('Continue with plan')}
{busy ? null : <ArrowRight size={16} aria-hidden data-i18n-mirror />}
</Action>
<Action render="button" type="button" disabled={busy || !enough} onClick={() => void advance()}>
{t('Submit application')}
</Action>
</>
) : (
<>
<Action render="button" type="button" fill disabled={!ready} onClick={next}>
{t('Continue')}
</Action>
<Action render="button" type="button" disabled={busy || !canSkip} onClick={() => setStep((s) => s + 1)}>
{t('Skip')}
</Action>
{enough ? (
<Action id="beta-plan-shortcut" render="button" type="button" disabled={busy} onClick={() => void continueWithPlan()}>
{t('Continue with plan')}
<ArrowRight size={15} aria-hidden data-i18n-mirror />
</Action>
) : null}
</>
)}
{!enough ? (
<Text fontSize="$2" color="var(--muted-foreground)">
{t('Answer {n} more to apply.', { n: need - count })}
</Text>
) : null}
</XStack>
{/* Honeypot field */}
<View render="label" aria-hidden position="absolute" l={-9999} width={1} height={1} overflow="hidden">
<span>{t('Leave this empty')}</span>
<input tabIndex={-1} autoComplete="off" name="website" value={trap} onChange={(e) => setTrap(e.target.value)} />
</View>
</YStack>
) : null}
</YStack>
</YStack>
)
}
function Progress({ done, total }: { done: number; total: number }) {
const { t } = useI18n()
const reached = Math.min(1, done / total) * STEPS
return (
<XStack gap={6} role="progressbar" aria-label={t('Application progress')} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((done / total) * 100)}>
{Array.from({ length: STEPS }, (_, n) => (
<View key={n} flex={1} height={4} rounded={9999} bg="var(--border)" overflow="hidden" aria-hidden>
<View height="100%" rounded={9999} bg="var(--foreground)" width={`${Math.max(0, Math.min(1, reached - n)) * 100}%`} $platform-web={{ transition: 'width 240ms ease' }} />
</View>
))}
</XStack>
)
}
/** Control in the site header bar. */
function Pill({ children, onPress }: { children: ReactNode; onPress: () => void }) {
return (
<Text
render={<button type="button" onClick={onPress} />}
display="flex"
items="center"
gap={6}
shrink={0}
height={34}
p={0}
px={12}
rounded={999}
borderWidth={0}
bg="transparent"
cursor="pointer"
color="var(--muted-foreground)"
fontSize="$2"
lineHeight={20}
fontWeight="500"
whiteSpace="nowrap"
hoverStyle={{ color: 'var(--foreground)', bg: '$ink/6', boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)' }}
>
{children}
</Text>
)
}
/** Letter key indicator. */
function Key({ children, on }: { children: ReactNode; on?: boolean }) {
return (
<View
aria-hidden
width={28}
height={28}
shrink={0}
items="center"
justify="center"
rounded={8}
borderWidth={1}
borderColor={on ? 'var(--foreground)' : 'var(--border-strong)'}
bg={on ? 'var(--foreground)' : 'transparent'}
>
<Text fontFamily="$mono" fontSize="$1" fontWeight="600" color={on ? 'var(--background)' : 'var(--muted-foreground)'}>
{children}
</Text>
</View>
)
}