mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
333 lines
16 KiB
TypeScript
333 lines
16 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} 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>
|
||
)
|
||
}
|