Files
zeekay 70349b34c7 beta: a post about one topic, with that topic's page and card
The share card offers Hanzo, the products and the betas the member asked
for, the rest under More by group. A topic picks the posts Shuffle offers,
the link (/beta/for/<topic>, tagged utm_content) and the card it unfurls
to: that page carries the topic's title, line and card, a canonical naming
itself and the one noindex (lib/publish SHARES), and sends a person on to
/beta with the query kept, so ref and the campaign land as before.

lib/hanzo/topics is the list, its words the site's and BETAS's own.
generate-og draws a card per topic from it, palette-quantized with sharp:
49 cards, 2.9 MB. The cards gate reads every card's rendered words.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 12:03:39 -07:00

331 lines
15 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, Copy, Mail, Pencil, Send, Share2, Shuffle } from 'lucide-react'
import { Facebook, Linkedin, Twitter } from '@/components/brand'
import { Input, Picker, 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)
const topic = (chosen && find(chosen)) || chips.find((x) => x.kind === 'beta') || 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')}>
{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>
<Picker
aria-label={t('More topics')}
value={rest.includes(topic) ? topic.id : ''}
onChange={(e) => e.target.value && choose(e.target.value)}
width={120}
shrink={0}
>
<option value="">{t('More')}</option>
{GROUPS.map((g) => {
const of = rest.filter((x) => x.group === g)
return of.length ? (
<optgroup key={g} label={t(g)}>
{of.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</optgroup>
) : null
})}
</Picker>
</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} onClick={() => void share()}>
<Share2 size={15} aria-hidden />
{t('Share')}
</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>
)
}