mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
/beta's portal is one component now. A stranger has it in the site's frame; a member has the app in its Betas mode (@hanzo/app 0.2.27: no sidebar there) with the same portal as its pane: the form if there is no application, a plan step once after filing (Pro with the beta code, or stay on Free), then the dashboard. Every share link, on the dashboard, the referral card, /referral and the page share widget, goes out through tagged(): the sharer's ref and the channel as utm_source, so a visit, a lead and a signup each say who shared and where. The page share widget's tracking was a no-op; it records waitlist_shared now. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
328 lines
9.7 KiB
TypeScript
328 lines
9.7 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect, useRef } from 'react'
|
||
import {
|
||
Share2,
|
||
Copy,
|
||
Check,
|
||
ChevronDown,
|
||
Sparkles,
|
||
ExternalLink,
|
||
} from 'lucide-react'
|
||
import {
|
||
Box,
|
||
Button,
|
||
Text,
|
||
Textarea,
|
||
XStack,
|
||
YStack,
|
||
toast,
|
||
} from '@hanzo/ui'
|
||
import { track } from '@hanzo/app/lib/tags'
|
||
import { mine, tagged } from '@/lib/hanzo/referrals'
|
||
|
||
export interface ShareWidgetProps {
|
||
/** The title displayed in the card header. */
|
||
title?: string
|
||
/** Default text prefilled in the textarea. */
|
||
defaultText?: string
|
||
/** Base URL to share. Defaults to current window.location.href */
|
||
url?: string
|
||
/** Handle to mention on social platforms. Defaults to 'hanzoai'. */
|
||
handle?: string
|
||
/** Compact mode suitable for inline embedding or doc sidebars. */
|
||
compact?: boolean
|
||
}
|
||
|
||
interface SocialOption {
|
||
id: string
|
||
name: string
|
||
icon: string | React.ReactNode
|
||
getUrl: (url: string, text: string, handle: string) => string
|
||
}
|
||
|
||
const SOCIAL_OPTIONS: SocialOption[] = [
|
||
{
|
||
id: 'x',
|
||
name: '𝕏 @hanzoai',
|
||
icon: '𝕏',
|
||
getUrl: (url, text, handle) =>
|
||
`https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}&via=${encodeURIComponent(handle)}`,
|
||
},
|
||
{
|
||
id: 'facebook',
|
||
name: 'Facebook',
|
||
icon: 'f',
|
||
getUrl: (url) =>
|
||
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||
},
|
||
{
|
||
id: 'linkedin',
|
||
name: 'LinkedIn',
|
||
icon: 'in',
|
||
getUrl: (url) =>
|
||
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||
},
|
||
{
|
||
id: 'reddit',
|
||
name: 'Reddit',
|
||
icon: 'r/',
|
||
getUrl: (url, text) =>
|
||
`https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`,
|
||
},
|
||
{
|
||
id: 'threads',
|
||
name: 'Threads',
|
||
icon: '@',
|
||
getUrl: (url, text) =>
|
||
`https://www.threads.net/intent/post?text=${encodeURIComponent(`${text} ${url}`)}`,
|
||
},
|
||
{
|
||
id: 'bluesky',
|
||
name: 'Bluesky',
|
||
icon: '🦋',
|
||
getUrl: (url, text) =>
|
||
`https://bsky.app/intent/compose?text=${encodeURIComponent(`${text} ${url}`)}`,
|
||
},
|
||
{
|
||
id: 'mastodon',
|
||
name: 'Mastodon',
|
||
icon: '🐘',
|
||
getUrl: (url, text) =>
|
||
`https://mastodon.social/share?text=${encodeURIComponent(`${text} ${url}`)}`,
|
||
},
|
||
]
|
||
|
||
/**
|
||
* Universal Share Widget matching Hanzo's design system & sponsor share pattern.
|
||
*
|
||
* Automatically resolves the logged-in user's referral code via `mine()`
|
||
* and appends `?ref=<code>` to the target share URL for viral attribution.
|
||
*/
|
||
export function ShareWidget({
|
||
title = "Share Hanzo",
|
||
defaultText = "Exploring Hanzo AI frontier models and agentic platform — 1M context, vision, and native reasoning.",
|
||
url: propUrl,
|
||
handle = 'hanzoai',
|
||
compact = false,
|
||
}: ShareWidgetProps) {
|
||
const [text, setText] = useState(defaultText)
|
||
const [shareUrl, setShareUrl] = useState(propUrl || '')
|
||
const [refCode, setRefCode] = useState<string | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
// 1. Resolve base URL
|
||
const rawUrl = propUrl || (typeof window !== 'undefined' ? window.location.href : 'https://hanzo.ai')
|
||
|
||
// 2. Fetch user's referral code to embed into share link
|
||
let active = true
|
||
mine()
|
||
.then((res) => {
|
||
if (!active) return
|
||
if (res.code) {
|
||
setRefCode(res.code)
|
||
try {
|
||
const u = new URL(rawUrl)
|
||
u.searchParams.set('ref', res.code)
|
||
setShareUrl(u.toString())
|
||
} catch {
|
||
setShareUrl(`${rawUrl}?ref=${res.code}`)
|
||
}
|
||
} else {
|
||
setShareUrl(rawUrl)
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (active) setShareUrl(rawUrl)
|
||
})
|
||
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [propUrl])
|
||
|
||
// Close dropdown on outside click
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||
setDropdownOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
const handleCopy = async () => {
|
||
track('waitlist_shared', { method: 'copy', from: 'page', ref: refCode ?? '' })
|
||
try {
|
||
await navigator.clipboard.writeText(`${text}\n\n${tagged(shareUrl, 'copy', 'share')}`)
|
||
setCopied(true)
|
||
toast.success('Share link and text copied to clipboard!')
|
||
setTimeout(() => setCopied(false), 2500)
|
||
} catch {
|
||
toast.error('Unable to copy to clipboard')
|
||
}
|
||
}
|
||
|
||
const handleSocialClick = (opt: SocialOption) => {
|
||
track('waitlist_shared', { method: opt.id, from: 'page', ref: refCode ?? '' })
|
||
setDropdownOpen(false)
|
||
const targetUrl = opt.getUrl(tagged(shareUrl, opt.id, 'share'), text, handle)
|
||
window.open(targetUrl, '_blank', 'noopener,noreferrer,width=600,height=500')
|
||
}
|
||
|
||
return (
|
||
<YStack
|
||
bg="var(--panel, #0a0a0a)"
|
||
borderWidth={1}
|
||
borderColor="var(--border, #222)"
|
||
rounded={16}
|
||
p={compact ? 16 : 24}
|
||
gap={16}
|
||
maxW={520}
|
||
width="100%"
|
||
>
|
||
{/* Header */}
|
||
<XStack items="center" gap={12}>
|
||
<Box
|
||
width={36}
|
||
height={36}
|
||
rounded={10}
|
||
bg="var(--surface-2, rgba(255,255,255,0.06))"
|
||
borderWidth={1}
|
||
borderColor="var(--border, rgba(255,255,255,0.1))"
|
||
items="center"
|
||
justify="center"
|
||
>
|
||
<Sparkles size={18} color="var(--foreground)" />
|
||
</Box>
|
||
<YStack gap={2}>
|
||
<Text fontSize="$4" fontWeight="600" color="var(--foreground)">
|
||
{title}
|
||
</Text>
|
||
{refCode ? (
|
||
<Text fontSize="$1" color="var(--muted-foreground)">
|
||
Referral active: <Text color="var(--foreground)" fontWeight="500">?ref={refCode}</Text>
|
||
</Text>
|
||
) : (
|
||
<Text fontSize="$1" color="var(--muted-foreground)">
|
||
Share with your network on social media
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
</XStack>
|
||
|
||
{/* Editable Share Text */}
|
||
<Textarea
|
||
value={text}
|
||
onChange={(e: any) => setText(e.target.value)}
|
||
rows={compact ? 2 : 3}
|
||
bg="var(--surface-1, rgba(255,255,255,0.03))"
|
||
borderColor="var(--border, rgba(255,255,255,0.1))"
|
||
color="var(--foreground)"
|
||
fontSize="$3"
|
||
rounded={12}
|
||
p={12}
|
||
$platform-web={{ resize: 'vertical' }}
|
||
/>
|
||
|
||
{/* Action Bar */}
|
||
<XStack items="center" justify="space-between" gap={12}>
|
||
{/* Dropdown Menu Container */}
|
||
<Box position="relative" ref={dropdownRef}>
|
||
<Button
|
||
type="button"
|
||
gap={8}
|
||
height={38}
|
||
px={14}
|
||
rounded={10}
|
||
bg="var(--surface-2, rgba(255,255,255,0.08))"
|
||
hoverStyle={{ bg: 'var(--surface-3, rgba(255,255,255,0.14))' }}
|
||
borderColor="var(--border, rgba(255,255,255,0.15))"
|
||
borderWidth={1}
|
||
onClick={() => setDropdownOpen((v) => !v)}
|
||
>
|
||
<Share2 size={15} />
|
||
<Text fontSize="$2" fontWeight="500" color="var(--foreground)">
|
||
Share
|
||
</Text>
|
||
<ChevronDown size={14} />
|
||
</Button>
|
||
|
||
{dropdownOpen && (
|
||
<Box
|
||
position="absolute"
|
||
t="100%"
|
||
l={0}
|
||
mt={6}
|
||
bg="var(--background, #090909)"
|
||
borderWidth={1}
|
||
borderColor="var(--border, #262626)"
|
||
rounded={12}
|
||
py={6}
|
||
width={200}
|
||
style={{ zIndex: 100 }}
|
||
shadowColor="#000"
|
||
shadowOffset={{ width: 0, height: 8 }}
|
||
shadowOpacity={0.4}
|
||
shadowRadius={16}
|
||
>
|
||
{SOCIAL_OPTIONS.map((opt) => (
|
||
<XStack
|
||
key={opt.id}
|
||
render={<button type="button" onClick={() => handleSocialClick(opt)} />}
|
||
items="center"
|
||
justify="space-between"
|
||
width="100%"
|
||
px={14}
|
||
py={9}
|
||
gap={10}
|
||
bg="transparent"
|
||
borderWidth={0}
|
||
cursor="pointer"
|
||
hoverStyle={{ bg: 'var(--surface-2, rgba(255,255,255,0.06))' }}
|
||
$platform-web={{ textAlign: 'left' }}
|
||
>
|
||
<XStack items="center" gap={10}>
|
||
<Text fontSize="$2" fontFamily="$mono" fontWeight="700" width={18}>
|
||
{opt.icon}
|
||
</Text>
|
||
<Text fontSize="$2" color="var(--foreground)">
|
||
{opt.name}
|
||
</Text>
|
||
</XStack>
|
||
<ExternalLink size={12} color="var(--muted-foreground)" />
|
||
</XStack>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Copy Link Button */}
|
||
<Button
|
||
type="button"
|
||
gap={8}
|
||
height={38}
|
||
px={14}
|
||
rounded={10}
|
||
bg="transparent"
|
||
borderColor="var(--border, rgba(255,255,255,0.12))"
|
||
borderWidth={1}
|
||
hoverStyle={{ borderColor: 'var(--foreground)' }}
|
||
onClick={handleCopy}
|
||
>
|
||
{copied ? <Check size={15} color="#22c55e" /> : <Copy size={15} />}
|
||
<Text fontSize="$2" fontWeight="500" color={copied ? '#22c55e' : 'var(--foreground)'}>
|
||
{copied ? 'Copied' : 'Copy link'}
|
||
</Text>
|
||
</Button>
|
||
</XStack>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
export default ShareWidget
|