Files
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

123 lines
4.8 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Text, Textarea, XStack } from '@hanzo/ui'
import { Action, Title } from '@hanzo/ui/marketing'
import { Card } from './card'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { track } from '@hanzo/app/lib/tags'
import { api } from '@hanzo/app/lib/api'
import { scope } from '@hanzo/app/lib/session'
import type { Program } from './program'
/** What feedback accepts, and the question each one is filed under. */
const KINDS = [
{ id: 'comment', label: phrase('Comment'), ask: phrase('A comment') },
{ id: 'note', label: phrase('Note'), ask: phrase('A note') },
{ id: 'message', label: phrase('Message'), ask: phrase('A message') },
{ id: 'bug', label: phrase('Bug'), ask: phrase('A bug') },
{ id: 'feature', label: phrase('Feature request'), ask: phrase('A feature request') },
] as const
type Kind = (typeof KINDS)[number]['id']
/** File one entry: a lead the team reads, then the same words on the waitlist. */
async function fileNote(kind: Kind, text: string, program: Program | null, email: string, name: string): Promise<'ok' | 'later' | 'no'> {
const row = KINDS.find((k) => k.id === kind) ?? KINDS[0]
const need = text.trim().slice(0, 4000)
const answer = need.slice(0, 2000)
if (!need) return 'no'
const headers = { 'Content-Type': 'application/json', ...scope() }
let lead: Response
try {
lead = await fetch(`${api()}/v1/marketing/leads`, {
method: 'POST',
headers,
body: JSON.stringify({
email,
name,
program: program ?? 'beta',
need,
answers: [{ question: row.ask, answer }],
source: 'hanzo.ai/beta',
}),
})
} catch {
return 'no'
}
if (lead.status === 429) return 'later'
if (!lead.ok) return 'no'
try {
const got = await fetch(`${api()}/v1/marketing/beta`, { headers: scope() })
if (!got.ok) return 'ok'
const rec = (await got.json()) as { program?: string; answers?: { id?: string; question?: string; answer?: string[] }[] }
const prior = (Array.isArray(rec.answers) ? rec.answers : []).flatMap((a) =>
a && a.id && a.question && Array.isArray(a.answer) && a.answer.length ? [{ id: a.id, question: a.question, answer: a.answer }] : [],
)
if (prior.length >= 40) return 'ok'
prior.push({ id: `${kind}-${Date.now()}`, question: row.ask, answer: [answer] })
await fetch(`${api()}/v1/marketing/beta`, {
method: 'PUT',
headers,
body: JSON.stringify({ program: rec.program || program || 'beta', answers: prior }),
})
} catch {
/* the lead is what the team reads */
}
return 'ok'
}
/** Feedback: one kind, the person's own words, sent to the team. */
export function Feedback({ program, email, name }: { program: Program | null; email: string; name: string }) {
const [kind, setKind] = useState<Kind>('comment')
const [text, setText] = useState('')
const [sending, setSending] = useState(false)
const [note, setNote] = useState('')
const { t } = useI18n()
const row = KINDS.find((k) => k.id === kind) ?? KINDS[0]
const send = async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
setNote('')
const result = await fileNote(kind, body, program, email, name)
setSending(false)
if (result === 'ok') {
track('beta_feedback_sent', { program: program ?? 'beta', kind })
setText('')
setNote(phrase('Sent. The team has it.'))
return
}
setNote(result === 'later' ? phrase('Too many for this hour. Your words are still here.') : phrase('That did not send. Your words are still here.'))
}
return (
<Card gap={16} data-slot="beta-feedback">
<Title quiet>{t('Tell the team')}</Title>
<XStack flexWrap="wrap" gap={8} role="radiogroup" aria-label={t('What you are sending')}>
{KINDS.map((k) => (
<Action key={k.id} render="button" type="button" role="radio" aria-checked={k.id === kind} fill={k.id === kind} onClick={() => setKind(k.id)}>
{t(k.label)}
</Action>
))}
</XStack>
{email ? (
<Textarea id="beta-feedback-note" rows={4} placeholder={`${t(row.ask)}…`} value={text} onChangeText={(v: string) => setText(v)} />
) : (
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Sign in so this is filed on your account.')}
</Text>
)}
<XStack items="center" gap={14} flexWrap="wrap">
<Action id="beta-feedback-send" render="button" type="button" fill disabled={!email || sending || text.trim() === ''} onClick={() => void send()}>
{sending ? t('Sending…') : t('Send')}
</Action>
{note ? (
<Text fontSize="$2" color="var(--muted-foreground)">
{t(note)}
</Text>
) : null}
</XStack>
</Card>
)
}