Files
Darkhorse7starsandGitHub b9b21ee094 beta: a plan is optional, the status follows, and the footer carries Join Beta (#101)
- Footer: Company carries Join Beta (/beta), beside the landing's banner.
- After filing, the plan step reads "Your application is in." with Continue
  with Pro or Skip for now; either way the dashboard, whose header says the
  status ("pending review" until paid). The dashboard's plan is offered beside
  it, not filled ahead of it.
- pay returns to `returnUrl` on its close control as on a purchase, so
  `/beta?checkout=success` means only "back from pay". Paid is billing's word
  or the `subscription` pay names; a reader who leaves pay unpaid lands on the
  status, never told they are in and never on the plan step again.
- Every way to pay from /beta returns to /beta (`RETURN`), not /chat.
- Gates: the skip, the unpaid return and the footer link. LLM.md states it.
2026-10-29 06:14:48 -04:00

843 lines
33 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, may pick a plan once or skip it, and has the dashboard: the
* application's status. A plan is never required to apply, and applying grants
* no access by itself: an application stays pending under the beta's rules.
* Every way to pay returns here (`RETURN`), and leaving pay's page without
* paying is that same return, so it lands on the status, never back in a cart. 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 { paid as pays, useTier } from '@hanzo/app/lib/tier'
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'
/**
* Where pay sends a buyer back, paid or not: pay's close control and a finished
* purchase both return to `returnUrl` (lib/checkout). So `checkout=success`
* says only that the reader came back from pay; paid is billing's word, or the
* subscription pay names on a finished purchase.
*/
const RETURN = '/beta?checkout=success'
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)
// Back from pay with a subscription named: paid, before billing has said so.
const [paid, setPaid] = useState(false)
// Back from pay at all, paid or not: the status, never the questions again.
const [returned, setReturned] = 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 || '')
// Billing's word on the plan: a reader back from pay without paying is an applicant.
const { tier } = useTier(hydrated && isAuthenticated)
const member = paid || pays(tier)
const quote = useQuote(COUPON)
const deal = quote && 'deals' in quote ? (quote.deals[PLAN] ?? Object.values(quote.deals)[0]) : undefined
// Restore in-progress application or handle checkout return
useEffect(() => {
const q = new URLSearchParams(window.location.search)
const kept = load()
if (q.get('checkout') === 'success') {
const subscription = q.get('subscription')
if (subscription) track('beta_paid', { subscription })
else track('beta_checkout_left', { program: kept?.program ?? 'beta' })
if (kept) {
setProgram(kept.program)
setGot(kept.got)
setFiled(Boolean(kept.filed))
setChose(kept.chose)
}
setPaid(Boolean(subscription))
setReturned(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
// Only resume an unsubmitted questionnaire on arrival: a signed-out visitor
// opening /beta starts the application rather than hitting an account wall
// left over from a previous session's `hz_beta_saved`.
if (kept && !kept.filed && (!namedProg || namedProg === kept.program)) {
setProgram(kept.program)
setStep(kept.step || 0)
setGot(kept.got || {})
setChose(kept.chose)
} else if (namedProg) {
setProgram(namedProg)
}
track('beta_viewed', {
initial_program: namedProg ?? (!kept?.filed ? kept?.program : undefined) ?? 'none',
resumed: Boolean(kept && !kept.filed),
})
}, [])
// 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
const syncSaved = async (kept: Saved | null) => {
if (!live || !kept?.filed || Object.keys(kept.got || {}).length === 0) return
setProgram(kept.program)
setGot(kept.got)
setChose(kept.chose)
if (!kept.chose) setJustFiled(true)
await file(kept.program, kept.got, kept.chose)
if (live) setSubmitted(true)
}
fetch(`${api()}/v1/marketing/beta`, { headers: scope() })
.then(async (r) => {
const kept = load()
if (!r.ok) {
await syncSaved(kept)
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 {
await syncSaved(kept)
}
})
.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 at the end before entering the Beta Dashboard
const hasAccount = Boolean(hydrated && isAuthenticated && scope().Authorization)
const needsAccountGate = (submitted || returned) && !hasAccount && !feedback
const isDashboard = (submitted || filed || returned) && hasAccount && !feedback
const asking = Boolean(program) && !isDashboard && !needsAccountGate && !feedback
// Right after filing, a member may pick Pro with the beta code or skip it, then has the dashboard.
const planning = isDashboard && justFiled && !member && !returned && !chose
useLayoutEffect(() => {
setBetaRunning(asking)
return () => setBetaRunning(false)
}, [asking])
const stepBack = () => {
if (feedback) return setFeedback(false)
if (isDashboard) {
setSubmitted(false)
setFiled(false)
setJustFiled(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(
forProgram: Program | null = program,
forGot: Record<string, Answer> = got,
forChose: 'free' | 'pro' | undefined = chose,
): Promise<void> {
if (!forProgram || trap) return
const forAsks = [...PROGRAMS[forProgram].asks, ...ABOUT]
const seen = visit()
const campaign = brought(window.location.search)
const text = (k: string) => (typeof forGot[k] === 'string' ? (forGot[k] as string).trim() : '')
const currentToken = hydrated && isAuthenticated ? named() : null
const targetEmail = hydrated && isAuthenticated ? user?.email || currentToken?.email || '' : ''
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(forAsks, forGot),
answers: answers(forAsks, forGot),
program: forProgram,
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 (hasAccount || targetEmail) {
try {
const mineAsks = forAsks.filter((a) => said(forGot[a.id]))
const now = mineAsks.map((a) => ({
id: a.id,
question: a.q,
answer: Array.isArray(forGot[a.id]) ? (forGot[a.id] as string[]) : [said(forGot[a.id])],
}))
await fetch(`${api()}/v1/marketing/beta`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...scope() },
body: JSON.stringify({ program: forProgram, answers: now }),
})
} catch {
/* lead already saved */
}
}
setFiled(true)
save({ program: forProgram, step: forAsks.length - 1, got: forGot, filed: true, chose: forChose })
track('beta_submitted', { program: forProgram })
mirror('beta_applied', { program: forProgram, 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 the application and open pay's plans with 50OFFBETA, chosen by the reader; leaving them returns to the status. */
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}${RETURN}`,
})
window.location.assign(target)
}
const next = () => {
if (!ask) return
advance()
}
// Keyboard navigation
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (isDashboard || needsAccountGate) 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('Create an account to finish your application.')}</Title>
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Create a free account or sign in to submit and see 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>
{justFiled ? (
<Quiet
render="button"
type="button"
onClick={() => {
const lastStep = Math.max(0, asks.length - 1)
setSubmitted(false)
setFiled(false)
setJustFiled(false)
setStep(lastStep)
if (program) save({ program, step: lastStep, got, filed: false, chose })
}}
>
{t('Edit answers')}
</Quiet>
) : null}
</XStack>
</Card>
) : planning ? (
<Card key="plan" gap={20}>
<YStack gap={8}>
<Title render="h1">{t('Your application is in.')}</Title>
<Text fontSize="$3" color="var(--muted-foreground)">
{deal
? t('A plan is optional: {percent}% off Pro for {span}, or skip it and keep your place in line.', { percent: deal.percent, span: span(deal.months) })
: t('A plan is optional: skip it and keep 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}${RETURN}` }))
}}
>
{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('Skip for now')}
</Action>
</XStack>
</Card>
) : isDashboard ? (
<BetaDashboard
email={mail}
name={who}
program={program ?? 'beta'}
paid={member}
checkoutUrl={plansStep({ coupon: COUPON, campaign: brought(window.location.search), returnUrl: `${window.location.origin}${RETURN}` })}
offer={deal ? t('{percent}% off Pro for {span}', { percent: deal.percent, span: span(deal.months) }) : undefined}
onEditApplication={() => {
setSubmitted(false)
setFiled(false)
setJustFiled(false)
setPaid(false)
setReturned(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>
)
}