From 36195f370be93c166fe68b2c24f4b6ba36367fff Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Sun, 4 Oct 2026 01:32:28 -0500 Subject: [PATCH] feat(sms): public /sms opt-in page with the same consent checkbox as checkout Twilio's toll-free verification needs a public page where a reviewer can see the SMS opt-in. FieldView's opt-in only appears inside flows (score alerts, subscribe, checkout) on game pages, and no public game page is live, so the number could not be verified. - web /sms explains the program (what is sent, frequency, rates, STOP/HELP, support email, no sharing of numbers or opt-in data) and has a working opt-in form: mobile number plus the product's unchecked SmsOptInCheckbox. - api POST /api/public/sms/opt-in records consent through SmsComplianceService.recordViewerConsent (E.164, source web:/sms, IP, user agent). It refuses without the box checked and never sends a text. A hidden honeypot field drops bots. - The public legal footer links "Text messages" to /sms. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/public.sms-opt-in.test.ts | 62 +++++++++++++ apps/api/src/routes/public.sms-opt-in.ts | 56 +++++++++++ apps/api/src/server.ts | 2 + apps/web/app/sms/page.tsx | 65 +++++++++++++ apps/web/components/PublicLegalFooter.tsx | 3 + apps/web/components/SmsOptInPageForm.tsx | 92 +++++++++++++++++++ .../__tests__/SmsOptInPageForm.test.tsx | 41 +++++++++ 7 files changed, 321 insertions(+) create mode 100644 apps/api/src/routes/__tests__/public.sms-opt-in.test.ts create mode 100644 apps/api/src/routes/public.sms-opt-in.ts create mode 100644 apps/web/app/sms/page.tsx create mode 100644 apps/web/components/SmsOptInPageForm.tsx create mode 100644 apps/web/components/__tests__/SmsOptInPageForm.test.tsx diff --git a/apps/api/src/routes/__tests__/public.sms-opt-in.test.ts b/apps/api/src/routes/__tests__/public.sms-opt-in.test.ts new file mode 100644 index 0000000..a49b6b3 --- /dev/null +++ b/apps/api/src/routes/__tests__/public.sms-opt-in.test.ts @@ -0,0 +1,62 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const recordViewerConsent = vi.fn().mockResolvedValue(undefined); +vi.mock('../../lib/sms/consentFromRequest', async (orig) => { + const actual = await orig(); + return { ...actual, getSmsComplianceFromRequest: () => ({ recordViewerConsent }) }; +}); + +import { errorHandler } from '../../middleware/errorHandler'; +import { createPublicSmsOptInRouter, SMS_OPT_IN_PAGE_SOURCE } from '../public.sms-opt-in'; + +function app() { + const a = express(); + a.use(express.json()); + a.use('/api/public', createPublicSmsOptInRouter()); + a.use(errorHandler); + return a; +} + +describe('POST /api/public/sms/opt-in', () => { + beforeEach(() => recordViewerConsent.mockClear()); + + it('records consent in E.164 with the page source, IP, and user agent', async () => { + const res = await request(app()) + .post('/api/public/sms/opt-in') + .set('x-forwarded-for', '203.0.113.9, 10.0.0.1') + .set('user-agent', 'test-agent') + .send({ phone: '(817) 555-0123', consent: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(recordViewerConsent).toHaveBeenCalledWith({ + phoneE164: '+18175550123', + source: SMS_OPT_IN_PAGE_SOURCE, + ipAddress: '203.0.113.9', + userAgent: 'test-agent', + }); + }); + + it('refuses without the checkbox: a phone number alone is not consent', async () => { + for (const consent of [false, undefined, 'true', 1]) { + const res = await request(app()).post('/api/public/sms/opt-in').send({ phone: '8175550123', consent }); + expect(res.status).toBe(400); + } + expect(recordViewerConsent).not.toHaveBeenCalled(); + }); + + it('refuses an invalid or missing phone number', async () => { + expect((await request(app()).post('/api/public/sms/opt-in').send({ phone: '123', consent: true })).status).toBe(400); + expect((await request(app()).post('/api/public/sms/opt-in').send({ consent: true })).status).toBe(400); + expect(recordViewerConsent).not.toHaveBeenCalled(); + }); + + it('silently drops a submission that fills the hidden honeypot field', async () => { + const res = await request(app()) + .post('/api/public/sms/opt-in') + .send({ phone: '8175550123', consent: true, website: 'spam.example' }); + expect(res.status).toBe(200); + expect(recordViewerConsent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/public.sms-opt-in.ts b/apps/api/src/routes/public.sms-opt-in.ts new file mode 100644 index 0000000..b79c7b0 --- /dev/null +++ b/apps/api/src/routes/public.sms-opt-in.ts @@ -0,0 +1,56 @@ +/** + * Public SMS opt-in from the /sms page (POST /api/public/sms/opt-in). + * + * Records consent only; it never sends a text. The person must check the box + * (consent: true); a phone number alone is not consent. A hidden honeypot + * field drops bots without saying so. + */ + +import express, { type Router, type Response, type NextFunction, type Request } from 'express'; +import { z } from 'zod'; + +import { BadRequestError } from '../lib/errors'; +import { parsePhoneToE164 } from '../lib/phone'; +import { getSmsComplianceFromRequest, requestClientMeta } from '../lib/sms/consentFromRequest'; + +export const SMS_OPT_IN_PAGE_SOURCE = 'web:/sms'; + +const OptInSchema = z.object({ + phone: z.string(), + consent: z.unknown(), + website: z.string().optional(), +}); + +export function createPublicSmsOptInRouter(): Router { + const router = express.Router(); + + router.post('/sms/opt-in', (req: Request, res: Response, next: NextFunction) => { + void (async () => { + try { + const parsed = OptInSchema.safeParse(req.body); + if (!parsed.success) throw new BadRequestError('Enter a valid mobile number.'); + const body = parsed.data; + + if (body.website && body.website.trim() !== '') { + res.json({ ok: true }); + return; + } + if (body.consent !== true) { + throw new BadRequestError('Check the box to agree to text messages.'); + } + const phoneE164 = parsePhoneToE164(body.phone); + + await getSmsComplianceFromRequest().recordViewerConsent({ + phoneE164, + source: SMS_OPT_IN_PAGE_SOURCE, + ...requestClientMeta(req), + }); + res.json({ ok: true }); + } catch (error) { + next(error); + } + })(); + }); + + return router; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d661a9f..9e8f07e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -33,6 +33,7 @@ import { createOwnersDirectStreamsRouter } from './routes/owners.direct-streams' import { createOwnersLedgerRouter } from './routes/owners.ledger'; import { createOwnersPaymentsRouter } from './routes/owners.payments'; import { createPublicRouter } from './routes/public.checkout'; +import { createPublicSmsOptInRouter } from './routes/public.sms-opt-in'; import { createPublicSubscriptionsRouter } from './routes/public.subscriptions'; import { createPublicGamesRouter } from './routes/public.games'; import { createPublicPurchasesRouter } from './routes/public.purchases'; @@ -137,6 +138,7 @@ app.use('/api/owners', createOwnersLedgerRouter()); app.use('/api/owners', createOwnersPaymentsRouter()); app.use('/api/public', createPublicRouter()); app.use('/api/public', createPublicSubscriptionsRouter()); +app.use('/api/public', createPublicSmsOptInRouter()); app.use('/api/public', createPublicGamesRouter()); app.use('/api/public', createPublicPurchasesRouter()); app.use('/api/public', createPublicWatchLinksRouter()); diff --git a/apps/web/app/sms/page.tsx b/apps/web/app/sms/page.tsx new file mode 100644 index 0000000..d0b09af --- /dev/null +++ b/apps/web/app/sms/page.tsx @@ -0,0 +1,65 @@ +import { SMS_BRAND, SMS_SUPPORT_EMAIL } from '@fieldview/data-model'; +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { SmsOptInPageForm } from '@/components/SmsOptInPageForm'; + +export const metadata: Metadata = { + title: 'Text messages', + description: `Opt in to ${SMS_BRAND} game alerts, score updates, and payment links by text message.`, +}; + +export default function SmsOptInPage() { + return ( +
+

Text messages from {SMS_BRAND}

+

+ {SMS_BRAND} streams youth sports games. Parents and fans can choose to get game alerts, score updates, and + payment links by text for the teams and games they follow or buy. These are messages about games you chose, + not marketing. +

+ +
    +
  • Every message starts with "{SMS_BRAND}:".
  • +
  • Message frequency varies with the games you follow.
  • +
  • Message and data rates may apply.
  • +
  • Reply STOP to opt out at any time, or HELP for help.
  • +
  • + Support:{' '} + + {SMS_SUPPORT_EMAIL} + +
  • +
  • + We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates + for marketing or promotional purposes. +
  • +
+ +
+

+ Opt in +

+

+ The same box appears wherever {SMS_BRAND} asks for a phone number: score alerts, game subscriptions, and + checkout. It is never checked for you, and you can watch or buy without it. +

+
+ +
+
+ +

+ Full details:{' '} + + SMS Terms + {' '} + and{' '} + + Privacy Policy + + . +

+
+ ); +} diff --git a/apps/web/components/PublicLegalFooter.tsx b/apps/web/components/PublicLegalFooter.tsx index 3567560..7683702 100644 --- a/apps/web/components/PublicLegalFooter.tsx +++ b/apps/web/components/PublicLegalFooter.tsx @@ -15,6 +15,9 @@ export function PublicLegalFooter() { Terms of Service + + Text messages + diff --git a/apps/web/components/SmsOptInPageForm.tsx b/apps/web/components/SmsOptInPageForm.tsx new file mode 100644 index 0000000..4983ffd --- /dev/null +++ b/apps/web/components/SmsOptInPageForm.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { SMS_BRAND } from '@fieldview/data-model'; +import { useState } from 'react'; + +import { SmsOptInCheckbox } from '@/components/SmsOptInCheckbox'; +import { apiRequest } from '@/lib/api-client'; + +type Status = { kind: 'idle' } | { kind: 'saving' } | { kind: 'done' } | { kind: 'error'; message: string }; + +/** Opt-in form on the public /sms page: mobile number + the product's unchecked consent box. */ +export function SmsOptInPageForm() { + const [phone, setPhone] = useState(''); + const [consent, setConsent] = useState(false); + const [website, setWebsite] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!consent) { + setStatus({ kind: 'error', message: 'Check the box to agree to text messages.' }); + return; + } + setStatus({ kind: 'saving' }); + try { + await apiRequest<{ ok: boolean }>('/api/public/sms/opt-in', { + method: 'POST', + body: JSON.stringify({ phone, consent, website }), + }); + setStatus({ kind: 'done' }); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : 'Something went wrong. Please try again.'; + setStatus({ kind: 'error', message }); + } + } + + if (status.kind === 'done') { + return ( +

+ You're opted in. {SMS_BRAND} may text you game alerts, score updates, and payment links. Message frequency + varies. Message and data rates may apply. Reply STOP to opt out, HELP for help. +

+ ); + } + + return ( +
+
+ + setPhone(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2" + data-testid="input-sms-phone" + required + /> +
+ + setWebsite(e.target.value)} + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + className="hidden" + /> + {status.kind === 'error' && ( +

+ {status.message} +

+ )} + + + ); +} diff --git a/apps/web/components/__tests__/SmsOptInPageForm.test.tsx b/apps/web/components/__tests__/SmsOptInPageForm.test.tsx new file mode 100644 index 0000000..c18ccc7 --- /dev/null +++ b/apps/web/components/__tests__/SmsOptInPageForm.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiRequest = vi.fn(); +vi.mock('@/lib/api-client', () => ({ apiRequest: (...args: unknown[]) => apiRequest(...args) })); + +import { SmsOptInPageForm } from '@/components/SmsOptInPageForm'; + +describe('SmsOptInPageForm (public /sms page)', () => { + beforeEach(() => apiRequest.mockReset()); + + it('shows the product consent box unchecked, with the full disclosure', () => { + render(); + const box = screen.getByTestId('checkbox-sms-opt-in') as HTMLInputElement; + expect(box.checked).toBe(false); + const text = screen.getByTestId('form-sms-opt-in').textContent || ''; + expect(text).toContain('Text me game alerts, score updates, and payment links from FieldView.Live.'); + expect(text).toContain('Message frequency varies. Message and data rates may apply.'); + expect(text).toContain('Reply STOP to opt out, HELP for help. Consent is not a condition of purchase.'); + }); + + it('does not submit until the person checks the box', async () => { + render(); + fireEvent.change(screen.getByTestId('input-sms-phone'), { target: { value: '8175550123' } }); + fireEvent.click(screen.getByTestId('btn-sms-opt-in')); + expect(await screen.findByTestId('sms-opt-in-error')).toBeTruthy(); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('posts phone and consent once the box is checked, then confirms', async () => { + apiRequest.mockResolvedValue({ ok: true }); + render(); + fireEvent.change(screen.getByTestId('input-sms-phone'), { target: { value: '8175550123' } }); + fireEvent.click(screen.getByTestId('checkbox-sms-opt-in')); + fireEvent.click(screen.getByTestId('btn-sms-opt-in')); + await waitFor(() => expect(screen.getByTestId('sms-opt-in-done')).toBeTruthy()); + const [endpoint, init] = apiRequest.mock.calls[0]; + expect(endpoint).toBe('/api/public/sms/opt-in'); + expect(JSON.parse(init.body)).toMatchObject({ phone: '8175550123', consent: true }); + }); +});