mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
249 lines
10 KiB
TypeScript
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're opted in
|
|
</Text>
|
|
<Text render="p" fontSize="$2" lineHeight="1.3203rem" text="center" color="var(--muted-foreground)">
|
|
Thanks — you'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>
|
|
);
|
|
}
|