mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The post starts on Hanzo; a beta the member asked for is a chip beside it, not the default. More is the library's dropdown, grouped by the betas' groups, in place of a native select. The chip row scrolls sideways on a phone with no scrollbar. The floating "Your Privacy Choices" button is not drawn: the choices are in every marketing footer, in the app's Settings and in the bar the region's rule draws.
331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
'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} 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>
|
||
)
|
||
}
|