Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/api/src/routes/__tests__/public.sms-opt-in.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../lib/sms/consentFromRequest')>();
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();
});
});
56 changes: 56 additions & 0 deletions apps/api/src/routes/public.sms-opt-in.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down
65 changes: 65 additions & 0 deletions apps/web/app/sms/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-2xl px-4 py-10" data-testid="page-sms">
<h1 className="text-3xl font-bold">Text messages from {SMS_BRAND}</h1>
<p className="mt-4 text-muted-foreground">
{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.
</p>

<ul className="mt-6 list-disc space-y-1 pl-6 text-sm" data-testid="sms-program-facts">
<li>Every message starts with &quot;{SMS_BRAND}:&quot;.</li>
<li>Message frequency varies with the games you follow.</li>
<li>Message and data rates may apply.</li>
<li>Reply STOP to opt out at any time, or HELP for help.</li>
<li>
Support:{' '}
<a href={`mailto:${SMS_SUPPORT_EMAIL}`} className="underline underline-offset-2">
{SMS_SUPPORT_EMAIL}
</a>
</li>
<li>
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.
</li>
</ul>

<section className="mt-8 rounded-lg border p-6" aria-labelledby="sms-opt-in-heading">
<h2 id="sms-opt-in-heading" className="text-lg font-semibold">
Opt in
</h2>
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
<div className="mt-4">
<SmsOptInPageForm />
</div>
</section>

<p className="mt-6 text-sm text-muted-foreground">
Full details:{' '}
<Link href="/terms#sms" className="underline underline-offset-2">
SMS Terms
</Link>{' '}
and{' '}
<Link href="/privacy" className="underline underline-offset-2">
Privacy Policy
</Link>
.
</p>
</main>
);
}
3 changes: 3 additions & 0 deletions apps/web/components/PublicLegalFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export function PublicLegalFooter() {
<Link href="/terms" data-testid="link-terms" className="hover:text-foreground underline-offset-4 hover:underline">
Terms of Service
</Link>
<Link href="/sms" data-testid="link-sms" className="hover:text-foreground underline-offset-4 hover:underline">
Text messages
</Link>
</nav>
</div>
</footer>
Expand Down
92 changes: 92 additions & 0 deletions apps/web/components/SmsOptInPageForm.tsx
Original file line number Diff line number Diff line change
@@ -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<Status>({ 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 (
<p className="rounded-md border border-green-600/30 bg-green-50 p-4 text-sm text-green-900" data-testid="sms-opt-in-done">
You&apos;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.
</p>
);
}

return (
<form onSubmit={onSubmit} className="space-y-4" data-testid="form-sms-opt-in" noValidate>
<div className="space-y-1">
<label htmlFor="sms-phone" className="block text-sm font-medium">
Mobile number
</label>
<input
id="sms-phone"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="(817) 555-0100"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2"
data-testid="input-sms-phone"
required
/>
</div>
<SmsOptInCheckbox checked={consent} onChange={setConsent} />
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="hidden"
/>
{status.kind === 'error' && (
<p className="text-sm text-red-600" role="alert" data-testid="sms-opt-in-error">
{status.message}
</p>
)}
<button
type="submit"
disabled={status.kind === 'saving'}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-60"
data-testid="btn-sms-opt-in"
>
{status.kind === 'saving' ? 'Saving…' : 'Agree and subscribe'}
</button>
</form>
);
}
41 changes: 41 additions & 0 deletions apps/web/components/__tests__/SmsOptInPageForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SmsOptInPageForm />);
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(<SmsOptInPageForm />);
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(<SmsOptInPageForm />);
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 });
});
});
Loading