mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
789 lines
30 KiB
TypeScript
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>
|
|
)
|
|
}
|