Files
zeekayandGitHub e20dc71ad8 i18n: /s* to /z*, the app's rooms and settings read their copy through t (#69)
The product pages from /s3 to /zap, /university and its course data,
/trust, /security, /sdks, /support, /tasks, /startups, /sms-opt-in, the
buying specialist, Zen, Kai's guide, Studio, the settings pages, and the
shared sales, solutions, zap and ui pieces. Client code draws with t(),
server components with <T>, copy held as data is marked with phrase()
and drawn through t() where it lands. A link or a figure inside a
sentence is a value, so a translator reorders the whole sentence.

Rebased on main: every page main moved onto Fold or Steps keeps main's
layout, with its words wrapped (label, title, lede, points). Steps now
draws a step's body through t, as it already drew the title. Settings
keeps main's usage panel, refresh, rewards wallet and sign-in wallet
rows, each label wrapped.

Left English: the skills page's install line (a command and two URLs),
and the solution eyebrow and sibling heading built from a kind label.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-29 23:51:44 -07:00

252 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'
import { useI18n } from '@hanzo/i18n/react'
// 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 { t } = useI18n();
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} />
{t('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"
>
{t('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"
>
{t('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)">
{t("You're opted in")}
</Text>
<Text render="p" fontSize="$2" lineHeight="1.3203rem" text="center" color="var(--muted-foreground)">
{t("Thanks — you'll now receive SMS from Hanzo at {phone}. You can reply {stop} at any time to opt out, or {help} for help.", {
phone,
stop: <Text render="strong" color={mix('var(--foreground)', 90)}>STOP</Text>,
help: <Text render="strong" color={mix('var(--foreground)', 90)}>HELP</Text>,
})}
</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">
{t('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={t('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" />}
{t('Yes, sign me up!')}
</Action>
<Text render="p" {...FINE} text="center" color={mix('var(--muted-foreground)', 80)}>
{/* 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. */}
{t('By submitting, you agree to our {terms} and {privacy}.', {
terms: <Text render={<Link href="/legal/terms" />} color="var(--foreground)">{t('Terms of Service')}</Text>,
privacy: <Text render={<Link href="/legal/privacy" />} color="var(--foreground)">{t('Privacy Policy')}</Text>,
})}{' '}
{t('We never sell or share your mobile number with third parties for marketing.')}
</Text>
</YStack>
</Box>
</motion.div>
)}
</Box>
</Box>
</main>
</Box>
);
}