Files
hanzo.ai/app/beta/status/page.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

113 lines
4.2 KiB
TypeScript

'use client'
/**
* /beta/status — a beta member's own page, the account menu's "Beta status".
*
* Signed out, it asks for the account. Signed in, it reads the account's beta
* record (GET /v1/marketing/beta) and its plan (GET /v1/billing/tier): a member
* who paid gets their access, the referral card and feedback (`Access`); one
* who applied and has not paid gets the same with Pro's checkout first; one with
* no application is sent to /beta to apply.
*/
import { useEffect, useState } from 'react'
import { Text, YStack } from '@hanzo/ui'
import { Action, Title } from '@hanzo/ui/marketing'
import { useIam } from '@hanzo/iam/react'
import { useI18n } from '@hanzo/i18n/react'
import { api } from '@hanzo/app/lib/api'
import { named, scope } from '@hanzo/app/lib/session'
import { paid, useTier } from '@hanzo/app/lib/tier'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { plans } from '@/lib/checkout'
import { BetaDashboard } from '@/components/beta/dashboard'
import type { Program } from '@/components/beta/program'
/** What the account's beta record says: the program applied to, none, or no answer. */
type Record = { state: 'reading' } | { state: 'none' } | { state: 'found'; program: Program } | { state: 'unread' }
const PROGRAMS: readonly Program[] = ['beta', 'research', 'partner']
export default function BetaStatus() {
const hydrated = useHydrated()
const { t } = useI18n()
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 answer, or its reason for none: a refusal reads as no paid plan, never as loading.
const { tier, wrong } = useTier(isAuthenticated)
const [record, setRecord] = useState<Record>({ state: 'reading' })
useEffect(() => {
if (!hydrated || !isAuthenticated) return
let live = true
fetch(`${api()}/v1/marketing/beta`, { headers: scope() })
.then(async (r) => {
if (r.status === 404) return { state: 'none' } as Record
if (!r.ok) return { state: 'unread' } as Record
const body = (await r.json()) as { program?: string }
const program = PROGRAMS.find((p) => p === body.program)
return program ? ({ state: 'found', program } as Record) : ({ state: 'none' } as Record)
})
.catch(() => ({ state: 'unread' }) as Record)
.then((r) => live && setRecord(r))
return () => {
live = false
}
}, [hydrated, isAuthenticated])
const member = paid(tier) || record.state === 'found'
let body
if (!hydrated || isLoading || (isAuthenticated && (record.state === 'reading' || (tier === null && !wrong)))) {
body = <Text fontSize="$3" color="var(--muted-foreground)">{t('Loading your beta…')}</Text>
} else if (!isAuthenticated) {
body = (
<Ask title={t('Your Hanzo beta.')}>
<Action href={`/login?next=${encodeURIComponent('/beta/status')}`} fill>
{t('Sign in')}
</Action>
</Ask>
)
} else if (!member) {
body = (
<Ask title={t('You have not joined the beta yet.')}>
<Action href="/beta" fill>
{t('Apply to the beta')}
</Action>
</Ask>
)
} else {
body = (
<BetaDashboard
email={mail}
name={who}
program={record.state === 'found' ? record.program : 'beta'}
paid={paid(tier)}
checkoutUrl={plans({ coupon: '50OFFBETA', returnUrl: `${window.location.origin}/beta` })}
onEditApplication={() => window.location.assign('/beta')}
/>
)
}
return (
<YStack render="section" aria-label={t('Your Hanzo beta')} minH="calc(100svh - var(--header, 64px))" px="var(--page-gutter)" pt={32} pb={56} items="center" justify="center">
<YStack width="100%" maxW={980} items="center">
{body}
</YStack>
</YStack>
)
}
/** A heading and the one way on. */
function Ask({ title, children }: { title: string; children: React.ReactNode }) {
return (
<YStack items="center" gap={20} maxW={560}>
<Title render="h1" text="center">
{title}
</Title>
{children}
</YStack>
)
}