Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

249 lines
10 KiB
TypeScript

'use client'
/**
* /sms-opt-in — the A2P consent surface: a number, an affirmative tick, and the
* disclosure shown beside both.
*
* NO TAILWIND except two prose-link hovers, named at their call sites. The form
* keeps its own element, its own `onSubmit` and its own field names — the
* `/account` `Form`/`Field` pattern is that section's and does not belong on a
* marketing page. What the fold moves is the layout AROUND the logic: the card
* is a `Box`, so `p-6 sm:p-8` stays a real breakpoint rather than collapsing to
* one value, and the `<form>` inside it is the stack it always was.
*
* The submit is `Action` at its filled weight, and the disabled dimming rides
* the SAME predicate that already computes `disabled` rather than a `disabled:`
* variant restating it, and is `undefined` when the control is live.
*/
import React, { useState } from "react";
import { motion } from "@/components/motion";
import Link from "next/link";
import { MessageSquare, Check } from "lucide-react";
import { Input, Spinner } from "@hanzo/gui";
import { SMS_CONSENT_TEXT } from "@/lib/constants/sms-consent";
import { Box, Checkbox, Text, XStack, YStack } from '@hanzo/ui'
import { Action, Display, Lede } from '@hanzo/ui/marketing'
import { mix } from '@hanzo/app/lib/mix'
import { api } from '@hanzo/app/lib/api'
// The endpoint that records consent (notify service, behind the gateway).
/** The consent intake, on the gateway of the host this page is open on. */
const intake = () => `${api()}/v1/notify/consent`;
/** The hairline card both states of this page are set in. */
const CARD = {
rounded: 'var(--radius-xl)',
borderWidth: 1,
borderColor: 'var(--neutral-800)',
bg: mix('var(--neutral-900)', 50),
} as const
/** The small print: `--text-xs` on `--leading-relaxed`. */
const FINE = { fontSize: '$1', lineHeight: '1.1172rem' } as const
export default function SmsOptInPage() {
const [phone, setPhone] = useState("");
const [consent, setConsent] = useState(false); // MUST default unchecked (A2P)
const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">("idle");
const valid = consent && phone.replace(/[^0-9]/g, "").length >= 10;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!valid) return;
setStatus("submitting");
try {
await fetch(intake(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone,
consent: true,
channel: "sms",
source: "hanzo.ai/sms-opt-in",
consentText: SMS_CONSENT_TEXT,
}),
});
// Show success regardless of backend availability — the opt-in intent is
// captured client-side and the disclosure was shown + affirmatively agreed.
setStatus("done");
} catch {
setStatus("done");
}
};
const off = !valid || status === "submitting";
return (
<Box minH="100vh">
<main>
{/* Hero */}
<Box
render="section"
position="relative"
pt={112}
pb={40}
px={16}
$md={{ px: 32 }}
$lg={{ px: 48 }}
>
<YStack width="100%" maxW={672} mx="auto" items="center">
<Text
render={<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} />}
display="inline-flex"
items="center"
gap={8}
px={12}
py={4}
mb={24}
rounded="var(--radius-full)"
bg={mix('var(--primary)', 15, 'srgb')}
fontSize="$1"
fontWeight="500"
color="var(--primary)"
>
<MessageSquare width={14} height={14} />
SMS Notifications
</Text>
<Display
render={<motion.h1 initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4, delay: 0.05 }} />}
mb={16}
text="center"
>
Get Hanzo text messages
</Display>
<Lede
render={<motion.p initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4, delay: 0.1 }} />}
text="center"
>
Opt in to receive two-factor authentication codes, account and security
alerts, and transactional notifications by SMS.
</Lede>
</YStack>
</Box>
{/* Form */}
<Box
render="section"
pb={96}
px={16}
$md={{ px: 32 }}
$lg={{ px: 48 }}
>
<Box width="100%" maxW={576} mx="auto">
{status === "done" ? (
<YStack render={<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} />} {...CARD} p={32}>
<Box
width={48}
height={48}
rounded="var(--radius-full)"
bg="$raised"
display="flex"
flexDirection="row"
items="center"
justify="center"
mx="auto"
mb={16}
>
<Check width={24} height={24} color="var(--pure-white)" />
</Box>
<Text render="h2" mb={8} fontSize="$6" lineHeight="1.4609rem" fontWeight="600" text="center" color="var(--text-primary)">
You&apos;re opted in
</Text>
<Text render="p" fontSize="$2" lineHeight="1.3203rem" text="center" color="var(--muted-foreground)">
Thanks — you&apos;ll now receive SMS from Hanzo at {phone}. You can reply{" "}
<Text render="strong" color={mix('var(--foreground)', 90)}>STOP</Text> at any time to opt out, or{" "}
<Text render="strong" color={mix('var(--foreground)', 90)}>HELP</Text> for help.
</Text>
</YStack>
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<Box p={24} $sm={{ p: 32 }} {...CARD}>
<YStack render="form" {...{ onSubmit: handleSubmit }} gap={24}>
<div>
<Text render="label" {...{ htmlFor: 'phone' }} display="block" mb={8} fontSize="$2" fontWeight="500" color="inherit">
Mobile phone number
</Text>
{/* gui's Input: a placeholder is a pseudo-element, and
`placeholderTextColor` is the prop that reaches it.
`unstyled` zeroes the outline, so the focus ring gets
its width back. */}
<Input
unstyled
id="phone"
name="phone"
{...{ type: 'tel', inputMode: 'tel', autoComplete: 'tel', required: true }}
placeholder="+1 (555) 000-0000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
width="100%"
px={16}
py={12}
rounded="var(--radius-lg)"
bg="var(--neutral-950)"
borderWidth={1}
borderColor="var(--neutral-800)"
fontSize="$2"
color="var(--foreground)"
placeholderTextColor="$placeholderColor"
focusVisibleStyle={{ outlineWidth: 2 }}
/>
</div>
{/* Consent checkbox — MUST start unchecked and be actively selected. */}
<XStack render="label" items="flex-start" gap={12} cursor="pointer" {...{ userSelect: 'none' }}>
<Checkbox
checked={consent}
onCheckedChange={(on) => setConsent(on === true)}
aria-label="I agree to receive SMS from Hanzo"
mt={4}
borderColor="var(--neutral-700)"
bg="var(--neutral-950)"
/>
<Text {...FINE} color="var(--muted-foreground)">
{SMS_CONSENT_TEXT}
</Text>
</XStack>
<Action
render="button"
type="submit"
disabled={off}
fill
width="100%"
justify="center"
opacity={off ? 0.4 : undefined}
>
{/* The button is a hardcoded white pill, so the glyph is black
rather than a theme token — it inherited `text-black` before. */}
{status === "submitting" && <Spinner size="small" color="black" />}
Yes, sign me up!
</Action>
<Text render="p" {...FINE} text="center" color={mix('var(--muted-foreground)', 80)}>
By submitting, you agree to our{" "}
{/* Links in a sentence, not controls: the underline appearing
on approach is the whole treatment, and @hanzo/design's
base already states it once for every link inside a
paragraph, so these state only their ink. */}
<Text render={<Link href="/legal/terms" />} color="var(--foreground)">Terms of Service</Text>{" "}
and{" "}
<Text render={<Link href="/legal/privacy" />} color="var(--foreground)">Privacy Policy</Text>.
We never sell or share your mobile number with third parties for marketing.
</Text>
</YStack>
</Box>
</motion.div>
)}
</Box>
</Box>
</main>
</Box>
);
}