mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Inline style objects became gui props (Box for a div, View or Text rendering the element for the rest, since Box styles only a div), raw colours and sizes became the design's tokens, the brand swatches read @hanzo/design's own values, and the project and creation hues moved to palette.json, which is data. The newsletter's subscribe button is the standard filled Action. connect-steps.tsx, which nothing imports, is deleted with the brand constants nobody read. Pages compared before and after at 1440x900 and 390x844: blockchain, brand, directory, projects, settings, unsubscribe identical; founder 0.03% of pixels; buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's button is now filled. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
449 lines
19 KiB
TypeScript
449 lines
19 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* /unsubscribe — Email Marketing Opt-Out & Preference Center.
|
|
*
|
|
* Compliant with CAN-SPAM Act, EU GDPR (Art. 7(3) withdrawal of consent),
|
|
* Canadian CASL, and RFC 8058 One-Click Unsubscribe.
|
|
*
|
|
* Features:
|
|
* - Pre-populates email from query string (?email=... or ?to=...).
|
|
* - Instant 1-click complete opt-out from all promotional lists.
|
|
* - Granular topic preferences for users who wish to customize their email mix.
|
|
* - Records opt-out directly to Hanzo CRM (POST https://api.hanzo.ai/v1/marketing/leads).
|
|
* - Clear disclosure of physical mailing address (995 Market St, SF CA).
|
|
* - Explains transactional email retention for operational security.
|
|
* - Full look and feel alignment with hanzo.ai design system.
|
|
*/
|
|
|
|
import React, { Suspense, useEffect, useState } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { CheckCircle2, MailX, ShieldCheck } from 'lucide-react'
|
|
import { Input, Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { HanzoMark } from '@hanzogui/shell'
|
|
import { Action, Display, Lede } from '@hanzo/ui/marketing'
|
|
import { Dither, Opening, Win } from '@/components/home/chrome'
|
|
import { Label } from '@/components/home/label'
|
|
|
|
const CRM_API = 'https://api.hanzo.ai/v1/marketing/leads'
|
|
|
|
const PREFERENCES = [
|
|
{
|
|
id: 'product',
|
|
label: 'Product Announcements & Model Releases',
|
|
desc: 'New frontier model weights, API capabilities, and engine updates.',
|
|
},
|
|
{
|
|
id: 'developer',
|
|
label: 'Developer Dispatch & Engineering News',
|
|
desc: 'Deep dives, benchmarks, architecture breakdowns, and MCP tooling.',
|
|
},
|
|
{
|
|
id: 'founder',
|
|
label: 'Founder Program & Open Source Bounties',
|
|
desc: 'OSS issues, bounty rewards, PR review digests, and Slack invites.',
|
|
},
|
|
{
|
|
id: 'offers',
|
|
label: 'Promotional Offers & Cloud Credits',
|
|
desc: 'Discount codes, seasonal coupons, and compute credits.',
|
|
},
|
|
] as const
|
|
|
|
function UnsubscribeContent() {
|
|
const searchParams = useSearchParams()
|
|
|
|
const rawEmail =
|
|
searchParams.get('email') ||
|
|
searchParams.get('address') ||
|
|
searchParams.get('addr') ||
|
|
searchParams.get('e') ||
|
|
searchParams.get('to') ||
|
|
searchParams.get('recipient') ||
|
|
searchParams.get('u') ||
|
|
''
|
|
const initialEmail = rawEmail ? decodeURIComponent(rawEmail).trim().toLowerCase() : ''
|
|
const isOneClick = searchParams.get('oneclick') === '1' || searchParams.get('auto') === '1'
|
|
const token = (searchParams.get('token') || searchParams.get('t') || searchParams.get('sig') || '').trim()
|
|
const org = (searchParams.get('org') || 'hanzo').trim()
|
|
const channel = (searchParams.get('channel') || 'email').trim().toLowerCase()
|
|
|
|
const [email, setEmail] = useState(initialEmail)
|
|
const [unsubscribeAll, setUnsubscribeAll] = useState(true)
|
|
const [selectedPrefs, setSelectedPrefs] = useState<Record<string, boolean>>({
|
|
product: false,
|
|
developer: false,
|
|
founder: false,
|
|
offers: false,
|
|
})
|
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'resubscribed' | 'error'>('idle')
|
|
const [errorMessage, setErrorMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (initialEmail && !email) {
|
|
setEmail(initialEmail)
|
|
}
|
|
}, [initialEmail, email])
|
|
|
|
// Automatic RFC 8058 one-click handling if explicitly flagged
|
|
useEffect(() => {
|
|
if (isOneClick && initialEmail && status === 'idle') {
|
|
performUnsubscribe(initialEmail, true)
|
|
}
|
|
}, [isOneClick, initialEmail, status])
|
|
|
|
const performUnsubscribe = async (targetEmail: string, all: boolean) => {
|
|
if (!targetEmail || !targetEmail.includes('@')) {
|
|
setErrorMessage('Please enter a valid email address.')
|
|
setStatus('error')
|
|
return
|
|
}
|
|
|
|
setStatus('submitting')
|
|
setErrorMessage('')
|
|
|
|
const normalized = targetEmail.trim().toLowerCase()
|
|
|
|
try {
|
|
await fetch(CRM_API, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: normalized,
|
|
consent: false,
|
|
stage: 'unsubscribed',
|
|
unsubscribed: true,
|
|
unsubscribedAll: all,
|
|
preferences: all ? [] : Object.keys(selectedPrefs).filter((k) => selectedPrefs[k]),
|
|
source: 'hanzo.ai/unsubscribe',
|
|
need: all ? 'Unsubscribed from all marketing communications' : 'Updated email preferences',
|
|
}),
|
|
})
|
|
|
|
// If signed token is present, also invoke the RFC 8058 HMAC endpoint
|
|
if (token) {
|
|
try {
|
|
await fetch(
|
|
`https://api.hanzo.ai/v1/marketing/unsubscribe?org=${encodeURIComponent(org)}&channel=${encodeURIComponent(
|
|
channel
|
|
)}&address=${encodeURIComponent(normalized)}&token=${encodeURIComponent(token)}`
|
|
)
|
|
} catch {
|
|
// Unsubscription record is already captured
|
|
}
|
|
}
|
|
|
|
setStatus('success')
|
|
} catch {
|
|
// For privacy & CAN-SPAM compliance, never block or trap opt-outs on network errors
|
|
setStatus('success')
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
await performUnsubscribe(email, unsubscribeAll)
|
|
}
|
|
|
|
const handleResubscribe = async () => {
|
|
setStatus('submitting')
|
|
try {
|
|
await fetch(CRM_API, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: email.trim().toLowerCase(),
|
|
consent: true,
|
|
stage: 'subscriber',
|
|
unsubscribed: false,
|
|
source: 'hanzo.ai/unsubscribe#resubscribe',
|
|
need: 'Re-subscribed via preferences page',
|
|
}),
|
|
})
|
|
setStatus('resubscribed')
|
|
} catch {
|
|
setStatus('resubscribed')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View width="100%" minH="100vh" position="relative" overflow="hidden" $platform-web={{ overflowX: 'clip', minHeight: '100vh' }}>
|
|
<Opening />
|
|
<Dither />
|
|
|
|
<View
|
|
render="main"
|
|
position="relative"
|
|
width="100%"
|
|
maxW={860}
|
|
mx="auto"
|
|
px="var(--page-gutter)"
|
|
pt="max(48px, min(8vh, 88px))"
|
|
pb="max(64px, min(10vh, 96px))"
|
|
>
|
|
<YStack gap="$5" items="center">
|
|
<Label center href="/">
|
|
HANZO.PREFERENCES
|
|
</Label>
|
|
|
|
<Display text="center" mt={4}>
|
|
<span>Email Preferences &</span>{' '}
|
|
<Text render="span" color="var(--muted-foreground)">
|
|
Opt-Out.
|
|
</Text>
|
|
</Display>
|
|
|
|
<Lede text="center" maxW={640} mb={6} $platform-web={{ textWrap: 'balance' }}>
|
|
We respect your inbox. Manage your email subscription preferences or unsubscribe from all marketing communications with a single click.
|
|
</Lede>
|
|
|
|
{/* Draggable Telemetry Window for Privacy Transparency */}
|
|
<View width="100%" maxW={680} mx="auto" my={4}>
|
|
<Win title="Privacy.ConsentCenter" icon={<HanzoMark size={14} />} slot="consent" open>
|
|
<YStack p={24} gap={20}>
|
|
{status === 'success' ? (
|
|
<YStack items="center" justify="center" p={24} gap={16}>
|
|
<View p={12} rounded={9999} bg="var(--secondary)" borderWidth={1} borderColor="var(--border)">
|
|
<CheckCircle2 size={40} color="var(--state-online)" />
|
|
</View>
|
|
|
|
<Text fontSize="$5" fontWeight="700" color="var(--foreground)" text="center">
|
|
You Have Been Unsubscribed
|
|
</Text>
|
|
|
|
<Text fontSize="$2" color="var(--muted-foreground)" maxW={480} lineHeight={22} text="center">
|
|
Marketing emails to <Text render="strong" color="var(--foreground)">{email}</Text> have been stopped.
|
|
{unsubscribeAll
|
|
? ' You have been removed from all promotional and newsletter distribution lists.'
|
|
: ' Your updated category preferences have been saved.'}
|
|
</Text>
|
|
|
|
<View p={16} rounded={14} bg="var(--secondary)" borderWidth={1} borderColor="var(--border)" width="100%" maxW={520} mt={4}>
|
|
<XStack items="flex-start" gap={10}>
|
|
<View mt={2} shrink={0}>
|
|
<ShieldCheck size={18} color="var(--muted-foreground)" />
|
|
</View>
|
|
<YStack gap={2}>
|
|
<Text fontSize="$2" fontWeight="600" color="var(--foreground)">
|
|
Transactional & Account Security Notice
|
|
</Text>
|
|
<Text fontSize="$1" color="var(--muted-foreground)" lineHeight={18}>
|
|
Critical transactional emails (password resets, cloud usage invoices, two-factor authentication codes, and security alerts) are required for service operation and cannot be unsubscribed.
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
</View>
|
|
|
|
<XStack items="center" gap={16} mt={8} flexWrap="wrap" justify="center">
|
|
<Action render="a" href="/" $platform-web={{ textDecoration: 'none' }}>
|
|
Return to Homepage →
|
|
</Action>
|
|
|
|
<Action render="button" type="button" onClick={handleResubscribe}>
|
|
Unsubscribed by mistake? Re-subscribe
|
|
</Action>
|
|
</XStack>
|
|
</YStack>
|
|
) : status === 'resubscribed' ? (
|
|
<YStack items="center" justify="center" p={24} gap={16}>
|
|
<CheckCircle2 size={40} color="var(--state-online)" />
|
|
<Text fontSize="$5" fontWeight="700" color="var(--foreground)" text="center">
|
|
Welcome Back!
|
|
</Text>
|
|
<Text fontSize="$2" color="var(--muted-foreground)" maxW={460} lineHeight={22} text="center">
|
|
Your subscription for <strong>{email}</strong> has been restored. You will receive our regular product announcements and engineering dispatches.
|
|
</Text>
|
|
<Action render="a" href="/" mt={8} $platform-web={{ textDecoration: 'none' }}>
|
|
Return to Homepage →
|
|
</Action>
|
|
</YStack>
|
|
) : (
|
|
<View
|
|
render={<form onSubmit={handleSubmit} />}
|
|
display="flex"
|
|
flexDirection="column"
|
|
gap={20}
|
|
>
|
|
{/* Email Input */}
|
|
<YStack gap={6}>
|
|
<Text fontSize="$2" fontWeight="600" color="var(--foreground)">
|
|
Email Address
|
|
</Text>
|
|
<Input
|
|
value={email}
|
|
onChangeText={setEmail}
|
|
placeholder="[email protected]"
|
|
keyboardType="email-address"
|
|
required
|
|
rounded={10}
|
|
bg="var(--secondary)"
|
|
borderColor="var(--border)"
|
|
$platform-web={{ borderRadius: '10px' }}
|
|
/>
|
|
</YStack>
|
|
|
|
{/* Unsubscribe All Radio */}
|
|
<YStack gap={12} pt={4}>
|
|
<XStack
|
|
items="flex-start"
|
|
gap={12}
|
|
p={14}
|
|
rounded={12}
|
|
bg={unsubscribeAll ? 'var(--secondary)' : 'transparent'}
|
|
borderWidth={1}
|
|
borderColor={unsubscribeAll ? 'var(--border-strong)' : 'var(--border)'}
|
|
cursor="pointer"
|
|
onClick={() => setUnsubscribeAll(true)}
|
|
$platform-web={{ borderRadius: '12px', transition: 'all 0.15s ease' }}
|
|
>
|
|
<View
|
|
render={<input type="radio" name="optout-choice" id="optout-all" checked={unsubscribeAll} onChange={() => setUnsubscribeAll(true)} />}
|
|
mt={3}
|
|
$platform-web={{ accentColor: "var(--foreground)", display: "inline-block" }}
|
|
/>
|
|
<YStack gap={2} flex={1}>
|
|
<Text
|
|
render={<label htmlFor="optout-all" />}
|
|
fontSize="$3"
|
|
fontWeight="600"
|
|
color="var(--foreground)"
|
|
cursor="pointer"
|
|
>
|
|
Unsubscribe from all marketing communications (Recommended)
|
|
</Text>
|
|
<Text fontSize="$1" color="var(--muted-foreground)" lineHeight={18}>
|
|
Opt out of all developer news, product releases, promotions, and newsletter issues.
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
|
|
{/* Custom Preferences Radio */}
|
|
<XStack
|
|
items="flex-start"
|
|
gap={12}
|
|
p={14}
|
|
rounded={12}
|
|
bg={!unsubscribeAll ? 'var(--secondary)' : 'transparent'}
|
|
borderWidth={1}
|
|
borderColor={!unsubscribeAll ? 'var(--border-strong)' : 'var(--border)'}
|
|
cursor="pointer"
|
|
onClick={() => setUnsubscribeAll(false)}
|
|
$platform-web={{ borderRadius: '12px', transition: 'all 0.15s ease' }}
|
|
>
|
|
<View
|
|
render={<input type="radio" name="optout-choice" id="optout-custom" checked={!unsubscribeAll} onChange={() => setUnsubscribeAll(false)} />}
|
|
mt={3}
|
|
$platform-web={{ accentColor: "var(--foreground)", display: "inline-block" }}
|
|
/>
|
|
<YStack gap={2} flex={1}>
|
|
<Text
|
|
render={<label htmlFor="optout-custom" />}
|
|
fontSize="$3"
|
|
fontWeight="600"
|
|
color="var(--foreground)"
|
|
cursor="pointer"
|
|
>
|
|
Customize my email topics
|
|
</Text>
|
|
<Text fontSize="$1" color="var(--muted-foreground)" lineHeight={18}>
|
|
Keep receiving specific topics and turn off others.
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
</YStack>
|
|
|
|
{/* Sub-Preferences Checklist if Custom Selected */}
|
|
{!unsubscribeAll ? (
|
|
<YStack gap={10} pl={28} pr={8}>
|
|
{PREFERENCES.map((pref) => {
|
|
const checked = Boolean(selectedPrefs[pref.id])
|
|
return (
|
|
<XStack
|
|
key={pref.id}
|
|
items="flex-start"
|
|
gap={10}
|
|
p={10}
|
|
rounded={10}
|
|
bg="var(--secondary)"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
>
|
|
<View
|
|
render={<input type="checkbox" id={`pref-${pref.id}`} checked={checked} onChange={(e) => {
|
|
setSelectedPrefs((prev) => ({ ...prev, [pref.id]: e.target.checked }))
|
|
}} />}
|
|
mt={3}
|
|
$platform-web={{ accentColor: "var(--foreground)", display: "inline-block" }}
|
|
/>
|
|
<YStack gap={2} flex={1}>
|
|
<Text
|
|
render={<label htmlFor={`pref-${pref.id}`} />}
|
|
fontSize="$2"
|
|
fontWeight="500"
|
|
color="var(--foreground)"
|
|
cursor="pointer"
|
|
>
|
|
{pref.label}
|
|
</Text>
|
|
<Text fontSize="$1" color="var(--muted-foreground)">
|
|
{pref.desc}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
)
|
|
})}
|
|
</YStack>
|
|
) : null}
|
|
|
|
{errorMessage ? (
|
|
<Text fontSize="$2" color="var(--destructive)">
|
|
{errorMessage}
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* Action Submit */}
|
|
<Action
|
|
fill
|
|
render="button"
|
|
type="submit"
|
|
disabled={status === 'submitting' || !email}
|
|
$platform-web={{ width: '100%', textAlign: 'center', justifyContent: 'center' }}
|
|
>
|
|
<XStack items="center" justify="center" gap={8}>
|
|
<MailX size={16} />
|
|
<span>{status === 'submitting' ? 'Updating Preferences...' : (unsubscribeAll ? 'Unsubscribe from All Emails' : 'Save Email Preferences')}</span>
|
|
</XStack>
|
|
</Action>
|
|
|
|
{/* Legal & Compliance Footer */}
|
|
<YStack gap={6} pt={10} borderTopWidth={1} borderColor="var(--border)">
|
|
<Text fontSize="$1" color="var(--muted-foreground)" lineHeight={18}>
|
|
Compliant with the CAN-SPAM Act, CASL, and EU GDPR Art. 7. Unsubscribe requests are processed immediately.
|
|
</Text>
|
|
<Text fontSize="$1" color="var(--muted-foreground)" lineHeight={18}>
|
|
Hanzo AI Inc. · 995 Market Street, San Francisco, CA 94103 · Questions? Contact <Text
|
|
render={<a href="mailto:[email protected]" />}
|
|
color="var(--foreground)"
|
|
textDecorationLine="underline"
|
|
>privacy@hanzo.ai</Text>
|
|
</Text>
|
|
</YStack>
|
|
</View>
|
|
)}
|
|
</YStack>
|
|
</Win>
|
|
</View>
|
|
</YStack>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export default function UnsubscribePage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<UnsubscribeContent />
|
|
</Suspense>
|
|
)
|
|
}
|