mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +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
113 lines
4.2 KiB
TypeScript
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>
|
|
)
|
|
}
|