Files
zeekay 51797774c6 beta: two buttons, Continue with plan until subscribed and Review application; Share is an icon on a phone
The portal's header offered Continue with plan, View plans and Review answers
(and Open app to a member already in the app). It offers Continue with plan to
a member who has not subscribed and Review application to everyone. On a phone
the link's Share button is its icon, so the link itself keeps the room.
2026-10-26 16:25:32 -07:00

333 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Check, ChevronDown, Copy, Mail, Pencil, Send, Share2, Shuffle } from 'lucide-react'
import { Facebook, Linkedin, Twitter } from '@/components/brand'
import { DropdownMenu, Input, Text, Textarea, XStack, YStack, toast, type GuiElement } from '@hanzo/ui'
import { Action, Title } from '@hanzo/ui/marketing'
import { useI18n } from '@hanzo/i18n/react'
import { track } from '@hanzo/app/lib/tags'
import { GROUPS } from '@hanzo/app/lib/betas'
import { about, tagged } from '@/lib/hanzo/referrals'
import { getOgImageUrl } from '@/lib/constants/og'
import { another, LONGEST } from '@/lib/hanzo/messages'
import { HANZO, TOPICS, page, topic as find, type Post, type Topic } from '@/lib/hanzo/topics'
import { Card, Quiet } from './card'
import { Bluesky, Reddit, Ycombinator } from './marks'
/** A channel's own page to share from: where it sends the member's link, tagged for it. */
const CHANNELS: { id: string; name: string; icon: ReactNode; to: (link: string, text: string) => string }[] = [
{ id: 'x', name: 'X', icon: <Twitter size={18} />, to: (u, t) => `https://x.com/intent/post?text=${encodeURIComponent(t)}&url=${encodeURIComponent(u)}` },
{ id: 'linkedin', name: 'LinkedIn', icon: <Linkedin size={18} />, to: (u) => `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(u)}` },
{ id: 'facebook', name: 'Facebook', icon: <Facebook size={18} />, to: (u) => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(u)}` },
{ id: 'bluesky', name: 'Bluesky', icon: <Bluesky size={18} />, to: (u, t) => `https://bsky.app/intent/compose?text=${encodeURIComponent(`${t} ${u}`)}` },
{ id: 'reddit', name: 'Reddit', icon: <Reddit size={18} />, to: (u, t) => `https://www.reddit.com/r/HanzoAI/submit?url=${encodeURIComponent(u)}&title=${encodeURIComponent(t)}` },
{ id: 'hn', name: 'Hacker News', icon: <Ycombinator size={18} />, to: (u, t) => `https://news.ycombinator.com/submitlink?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}` },
{ id: 'email', name: 'Email', icon: <Mail size={18} />, to: (u, t) => `mailto:?subject=${encodeURIComponent(t)}&body=${encodeURIComponent(`${t}\n\n${u}`)}` },
]
/** The topics always offered: Hanzo and its products. A beta is offered once the member asks for it. */
const ALWAYS = TOPICS.filter((x) => x.kind !== 'beta')
/**
* Step 1: the member's link, once, and the ways out with it. Nothing renders
* until the link exists: a press before it would share no link at all. Every way
* out goes through `tagged` (`ref` and `utm_source=<channel>`), and a press is
* one `waitlist_shared` event named for its channel.
*
* The post is about a topic (lib/hanzo/topics): Hanzo, a product, or a beta, the
* first the member asked for (`asked`) until they pick. The topic decides the
* posts Shuffle offers, the link (its page, /beta/for/<topic>, tagged
* `utm_content=<topic>`) and the card the link unfurls to, which is the image
* that page's own metadata names.
*/
export function Share({
link,
code,
program,
rename,
asked = [],
onward = true,
}: {
link: string
code: string
program: string
rename: (code: string) => Promise<string>
asked?: string[]
onward?: boolean
}) {
const { t } = useI18n()
const [copied, setCopied] = useState(false)
const [naming, setNaming] = useState(false)
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [invites, setInvites] = useState('')
const native = typeof navigator !== 'undefined' && typeof navigator.share === 'function'
// The chips: Hanzo, the products, then each beta the member asked for. The rest
// are under More, by their group.
const chips = useMemo(() => [...ALWAYS, ...asked.map(find).filter((x): x is Topic => x?.kind === 'beta')], [asked])
const rest = useMemo(() => TOPICS.filter((x) => !chips.includes(x)), [chips])
const [chosen, setChosen] = useState<string | null>(null)
// Hanzo until the member picks: a beta they asked for is a chip beside it, never the default.
const topic = (chosen && find(chosen)) || find(HANZO)!
// Which line is offered: the first until the page is up, then one chosen at random, so
// the export holds one answer and a reader never sees it change under them.
const [line, setLine] = useState(0)
const [own, setOwn] = useState<string | null>(null)
const [writing, setWriting] = useState(false)
useEffect(() => setLine(another(-1, topic.posts.length)), [topic.id, topic.posts.length])
// The chosen chip is in view: on a phone the row scrolls, and a beta asked for sits past the products.
const row = useRef<GuiElement>(null)
useEffect(() => {
const r = row.current as HTMLElement | null
const c = r?.querySelector<HTMLElement>('[aria-pressed="true"]')
if (!r || !c) return
const rb = r.getBoundingClientRect()
const cb = c.getBoundingClientRect()
if (cb.left < rb.left || cb.right > rb.right) r.scrollTo({ left: r.scrollLeft + cb.left - rb.left - (rb.width - cb.width) / 2, behavior: 'smooth' })
}, [topic.id, link])
const said = (p: Post) => t(p.text, { name: topic.name, line: t(topic.line) })
const shown = topic.posts[line % topic.posts.length]
const post = own ?? said(shown)
const kind = own === null ? shown.id : 'own'
// What the link unfurls to: the title its page carries (app/beta/for/[topic]).
const heading = topic.id === HANZO ? t('Get there first — Hanzo AI') : `${topic.name} — Hanzo AI`
const choose = (id: string) => {
setChosen(id)
setOwn(null)
setWriting(false)
}
const via = (channel: string) => tagged(link, channel, 'beta_referral', topic.id)
const heard = (method: string) => track('waitlist_shared', { method, program, from: 'beta', topic: topic.id, message: kind })
const copy = async () => {
heard('copy')
try {
await navigator.clipboard.writeText(via('copy'))
setCopied(true)
window.setTimeout(() => setCopied(false), 2400)
} catch {
toast.error(t('Copy failed. Select the link and copy it.'))
}
}
const share = async () => {
heard('native')
await navigator.share({ title: heading, text: post, url: via('native') }).catch(() => undefined)
}
const save = async (e: React.FormEvent) => {
e.preventDefault()
const clean = draft.trim().replace(/[^a-zA-Z0-9_-]/g, '')
if (clean.length < 2 || clean.length > 32) return toast.error(t('2 to 32 letters, numbers or hyphens.'))
setSaving(true)
try {
await rename(clean)
setNaming(false)
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Could not save the link.'))
} finally {
setSaving(false)
}
}
const invite = (e: React.FormEvent) => {
e.preventDefault()
const to = invites.split(/[\s,;]+/).filter((x) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(x))
if (!to.length) return toast.error(t('Enter an email address.'))
track('beta_network_invited', { count: to.length })
const url = via('invite')
const body = `${t('Hey,')}\n\n${t('I’m using Hanzo to build with agents and frontier models. You can get early access to the beta with my link:')}\n\n${url}`
window.location.href = `mailto:${to.join(',')}?subject=${encodeURIComponent(t('Join me in the Hanzo beta'))}&body=${encodeURIComponent(body)}`
setInvites('')
}
return (
<YStack gap={16}>
<Card data-slot="beta-share" gap={16}>
<Title quiet>{t('Your link')}</Title>
{link ? (
<>
<XStack items="center" gap={8} data-slot="beta-topics">
<XStack ref={row} flex={1} minW={0} gap={8} overflowX="auto" role="group" aria-label={t('Share about')} data-slot="beta-topic-row">
{chips.map((x) => (
<Action key={x.id} render="button" type="button" shrink={0} fill={x.id === topic.id} aria-pressed={x.id === topic.id} onClick={() => choose(x.id)}>
{x.name}
</Action>
))}
</XStack>
<DropdownMenu
placement="bottom-end"
minWidth={220}
maxHeight={360}
trigger={
<Action render="button" type="button" shrink={0} fill={rest.includes(topic)} aria-label={t('More topics')} aria-haspopup="menu">
{rest.includes(topic) ? topic.name : t('More')}
<ChevronDown size={14} aria-hidden />
</Action>
}
items={GROUPS.flatMap((g) => {
const of = rest.filter((x) => x.group === g)
return of.length
? [
{ type: 'label' as const, key: `group-${g}`, label: t(g) },
...of.map((x) => ({ key: x.id, label: x.name, selected: x.id === topic.id, onSelect: () => choose(x.id) })),
]
: []
})}
/>
</XStack>
<XStack items="center" gap={8} pl={16} pr={6} height={48} rounded={999} borderWidth={1} borderColor="var(--border)" bg="var(--muted)">
<Text flex={1} minW={0} fontFamily="$mono" fontSize="$3" color="var(--foreground)" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
{about(link, topic.id)}
</Text>
<Action render="button" type="button" fill height={36} aria-label={t('Copy link')} onClick={() => void copy()}>
{copied ? <Check size={15} aria-hidden /> : <Copy size={15} aria-hidden />}
{copied ? t('Copied') : t('Copy')}
</Action>
{native ? (
<Action render="button" type="button" height={36} aria-label={t('Share')} onClick={() => void share()} $max-sm={{ px: 10 }}>
<Share2 size={15} aria-hidden />
<Text display="flex" $max-sm={{ display: 'none' }}>
{t('Share')}
</Text>
</Action>
) : null}
</XStack>
<YStack gap={12} data-slot="beta-post">
<XStack items="center" justify="space-between" gap={12} flexWrap="wrap">
<Text fontFamily="$mono" fontSize="$2" color="var(--muted-foreground)">
{t('Your post')}
</Text>
<XStack gap={16}>
<Quiet
render="button"
type="button"
onClick={() => {
setOwn(null)
setWriting(false)
setLine(another(line % topic.posts.length, topic.posts.length))
}}
>
<Shuffle size={14} aria-hidden />
{t('Shuffle')}
</Quiet>
<Quiet
render="button"
type="button"
aria-pressed={writing}
onClick={() => {
if (!writing && own === null) setOwn(post)
setWriting(!writing)
}}
>
<Pencil size={14} aria-hidden />
{writing ? t('Done') : t('Write your own')}
</Quiet>
</XStack>
</XStack>
{writing ? (
<YStack gap={4}>
<Textarea
rows={3}
maxLength={LONGEST}
aria-label={t('Your post')}
value={own ?? ''}
onChangeText={(v: string) => setOwn(v.slice(0, LONGEST))}
/>
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" self="flex-end">
{(own ?? '').length} / {LONGEST}
</Text>
</YStack>
) : (
<Text fontSize="$4" lineHeight={24} color="var(--foreground)" data-slot="beta-post-text">
{post}
</Text>
)}
<YStack rounded={14} borderWidth={1} borderColor="var(--border)" overflow="hidden" bg="var(--background)" data-slot="beta-unfurl" aria-label={t('How the link looks when it is shared')}>
<img src={getOgImageUrl(page(topic.id))} alt="" width={1200} height={630} style={{ width: '100%', height: 'auto', display: 'block' }} />
<YStack gap={2} px={14} py={10} borderTopWidth={1} borderColor="var(--border)">
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)">
hanzo.ai
</Text>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)" data-slot="beta-unfurl-title">
{heading}
</Text>
<Text fontSize="$2" color="var(--muted-foreground)" data-slot="beta-unfurl-line">
{t(topic.line)}
</Text>
</YStack>
</YStack>
</YStack>
<XStack items="center" justify="space-between" gap={12} flexWrap="wrap">
<XStack gap={8} flexWrap="wrap" role="group" aria-label={t('Share on')}>
{CHANNELS.map((c) => (
<Action
key={c.id}
render={<a href={c.to(via(c.id), post)} target="_blank" rel="noopener noreferrer" aria-label={t('Share on {name}', { name: c.name })} title={c.name} onClick={() => heard(c.id)} />}
width={40}
height={40}
p={0}
items="center"
justify="center"
>
{c.icon}
</Action>
))}
</XStack>
{code && !naming ? (
<Quiet
render="button"
type="button"
onClick={() => {
setDraft(code)
setNaming(true)
}}
>
{t('Customize link')}
</Quiet>
) : null}
</XStack>
{code && naming ? (
<form onSubmit={save}>
<XStack items="center" gap={8} flexWrap="wrap">
<Text fontFamily="$mono" fontSize="$2" color="var(--muted-foreground)">
hanzo.ai/beta?ref=
</Text>
<Input height={44} flex={1} minW={140} maxW={240} placeholder="yourname" value={draft} onChange={(e) => setDraft(e.target.value)} />
<Action render="button" type="submit" fill disabled={saving}>
{t('Save')}
</Action>
<Action render="button" type="button" onClick={() => setNaming(false)}>
{t('Cancel')}
</Action>
</XStack>
</form>
) : null}
</>
) : null}
</Card>
<Card bg="transparent" data-slot="beta-invite">
<Title quiet>{t('Invite by email')}</Title>
<form onSubmit={invite}>
<XStack items="center" gap={10} flexWrap="wrap">
<Input height={44} flex={1} minW={0} flexBasis={240} inputMode="email" placeholder={t('[email protected]')} aria-label={t('Email addresses')} value={invites} onChange={(e) => setInvites(e.target.value)} />
<Action render="button" type="submit" disabled={!link}>
<Send size={15} aria-hidden />
{t('Send')}
</Action>
</XStack>
</form>
</Card>
{onward ? (
<XStack>
<Quiet href="/referral">{t('Referral dashboard')}</Quiet>
</XStack>
) : null}
</YStack>
)
}