From 720de2614cb9410581ed3400c773900ae540d045 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Tue, 6 Oct 2026 19:48:14 -0500 Subject: [PATCH 1/3] feat: add fleet metadata to health endpoints (#246) Co-authored-by: Cursor Agent --- apps/api/__tests__/integration/health.test.ts | 7 +++ .../src/lib/__tests__/health-fleet.test.ts | 45 +++++++++++++++++++ apps/api/src/lib/health-fleet.ts | 30 +++++++++++++ apps/api/src/routes/health.ts | 2 + apps/web/app/api/health/route.ts | 13 ++++++ apps/web/lib/health-fleet.test.ts | 40 +++++++++++++++++ apps/web/lib/health-fleet.ts | 14 ++++++ 7 files changed, 151 insertions(+) create mode 100644 apps/api/src/lib/__tests__/health-fleet.test.ts create mode 100644 apps/api/src/lib/health-fleet.ts create mode 100644 apps/web/app/api/health/route.ts create mode 100644 apps/web/lib/health-fleet.test.ts create mode 100644 apps/web/lib/health-fleet.ts diff --git a/apps/api/__tests__/integration/health.test.ts b/apps/api/__tests__/integration/health.test.ts index 4f0da75..0d73704 100644 --- a/apps/api/__tests__/integration/health.test.ts +++ b/apps/api/__tests__/integration/health.test.ts @@ -14,5 +14,12 @@ describe('Health Endpoint', () => { expect(response.body.checks).toBeDefined(); expect(response.body.checks.database).toBeDefined(); expect(response.body.checks.redis).toBeDefined(); + + expect(typeof response.body.ok).toBe('boolean'); + expect(response.body.service).toBe('fieldview-api'); + expect(typeof response.body.commit).toBe('string'); + expect(typeof response.body.env).toBe('string'); + expect(typeof response.body.utc).toBe('string'); + expect(response.body.ok).toBe(response.body.status === 'healthy'); }); }); diff --git a/apps/api/src/lib/__tests__/health-fleet.test.ts b/apps/api/src/lib/__tests__/health-fleet.test.ts new file mode 100644 index 0000000..62d5c8c --- /dev/null +++ b/apps/api/src/lib/__tests__/health-fleet.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveHealthCommit } from '../health-fleet'; + +const COMMIT_KEYS = [ + 'RAILWAY_GIT_COMMIT_SHA', + 'VERCEL_GIT_COMMIT_SHA', + 'GIT_COMMIT', +] as const; + +describe('resolveHealthCommit', () => { + const saved: Partial> = {}; + + beforeEach(() => { + for (const key of COMMIT_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of COMMIT_KEYS) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('returns RAILWAY_GIT_COMMIT_SHA when set', () => { + process.env.RAILWAY_GIT_COMMIT_SHA = 'abc123fullsha'; + expect(resolveHealthCommit()).toBe('abc123fullsha'); + }); + + it('returns unknown when all commit env vars are unset', () => { + expect(resolveHealthCommit()).toBe('unknown'); + }); + + it('falls back to GIT_COMMIT when Railway and Vercel are unset', () => { + process.env.GIT_COMMIT = 'localsha'; + expect(resolveHealthCommit()).toBe('localsha'); + }); +}); diff --git a/apps/api/src/lib/health-fleet.ts b/apps/api/src/lib/health-fleet.ts new file mode 100644 index 0000000..a5f78af --- /dev/null +++ b/apps/api/src/lib/health-fleet.ts @@ -0,0 +1,30 @@ +/** Fleet health contract: build identity fields for GET /health. */ + +export function resolveHealthCommit(): string { + return ( + process.env.RAILWAY_GIT_COMMIT_SHA ?? + process.env.VERCEL_GIT_COMMIT_SHA ?? + process.env.GIT_COMMIT ?? + 'unknown' + ); +} + +export function resolveHealthEnv(): string { + return process.env.APP_ENV ?? process.env.RAILWAY_ENVIRONMENT_NAME ?? 'dev'; +} + +export function buildFleetHealthFields(ok: boolean, service: string): { + ok: boolean; + service: string; + commit: string; + env: string; + utc: string; +} { + return { + ok, + service, + commit: resolveHealthCommit(), + env: resolveHealthEnv(), + utc: new Date().toISOString(), + }; +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index 0876ad3..cf866cd 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -6,6 +6,7 @@ import express, { type Request, Response, Router } from 'express'; +import { buildFleetHealthFields } from '../lib/health-fleet'; import { prisma } from '../lib/prisma'; import { redisClient } from '../lib/redis'; @@ -90,6 +91,7 @@ export function createHealthRouter(): Router { timestamp: new Date().toISOString(), checks, email: checkEmailProvider(), + ...buildFleetHealthFields(isHealthy, 'fieldview-api'), }); }); diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..a2118d4 --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +import { resolveHealthCommit, resolveHealthEnv } from '@/lib/health-fleet'; + +export async function GET(): Promise { + return NextResponse.json({ + ok: true, + service: 'fieldview-web', + commit: resolveHealthCommit(), + env: resolveHealthEnv(), + utc: new Date().toISOString(), + }); +} diff --git a/apps/web/lib/health-fleet.test.ts b/apps/web/lib/health-fleet.test.ts new file mode 100644 index 0000000..d00abf5 --- /dev/null +++ b/apps/web/lib/health-fleet.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveHealthCommit } from './health-fleet'; + +const COMMIT_KEYS = [ + 'RAILWAY_GIT_COMMIT_SHA', + 'VERCEL_GIT_COMMIT_SHA', + 'GIT_COMMIT', +] as const; + +describe('resolveHealthCommit', () => { + const saved: Partial> = {}; + + beforeEach(() => { + for (const key of COMMIT_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of COMMIT_KEYS) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('returns RAILWAY_GIT_COMMIT_SHA when set', () => { + process.env.RAILWAY_GIT_COMMIT_SHA = 'abc123fullsha'; + expect(resolveHealthCommit()).toBe('abc123fullsha'); + }); + + it('returns unknown when all commit env vars are unset', () => { + expect(resolveHealthCommit()).toBe('unknown'); + }); +}); diff --git a/apps/web/lib/health-fleet.ts b/apps/web/lib/health-fleet.ts new file mode 100644 index 0000000..294ffe5 --- /dev/null +++ b/apps/web/lib/health-fleet.ts @@ -0,0 +1,14 @@ +/** Fleet health contract: build identity fields for GET /api/health. */ + +export function resolveHealthCommit(): string { + return ( + process.env.RAILWAY_GIT_COMMIT_SHA ?? + process.env.VERCEL_GIT_COMMIT_SHA ?? + process.env.GIT_COMMIT ?? + 'unknown' + ); +} + +export function resolveHealthEnv(): string { + return process.env.APP_ENV ?? process.env.RAILWAY_ENVIRONMENT_NAME ?? 'dev'; +} From 1232d080e07f4c76493717ec2ba68257012540ce Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Sun, 11 Oct 2026 13:01:45 -0500 Subject: [PATCH 2/3] fix(web): public home page without the stale Coming Soon popup (#247) Twilio rejected the FieldView.Live toll-free verification with 30491 ("Website Is Password Protected or Requires Login"): the home page opened with a "Coming Soon / launch Feb 15, 2026" popup over Owner Login and Admin links, and rendered client-side only because the popup hook read search params. - Remove ComingSoonModal and its useWelcomeModal hook (home page was the only user). - Add an "About FieldView.Live" section: what it is, operated by Noctusoft, Inc., support@fieldview.live, links to /sms, /privacy, /terms. - The home page is now a static server component, so the content is in the HTML a reviewer fetches. Claude-Session: https://claude.ai/code/session_0183M8krzbhhBfqP9Efw7Pcb Co-authored-by: Claude Opus 5.5 --- apps/web/app/__tests__/home-page.test.tsx | 55 ++++++ apps/web/app/page.tsx | 70 ++++--- apps/web/components/v2/ComingSoonModal.tsx | 212 --------------------- apps/web/hooks/useWelcomeModal.ts | 118 ------------ 4 files changed, 97 insertions(+), 358 deletions(-) create mode 100644 apps/web/app/__tests__/home-page.test.tsx delete mode 100644 apps/web/components/v2/ComingSoonModal.tsx delete mode 100644 apps/web/hooks/useWelcomeModal.ts diff --git a/apps/web/app/__tests__/home-page.test.tsx b/apps/web/app/__tests__/home-page.test.tsx new file mode 100644 index 0000000..1801d7b --- /dev/null +++ b/apps/web/app/__tests__/home-page.test.tsx @@ -0,0 +1,55 @@ +/** + * Home page must be fully public: carrier reviewers (Twilio toll-free + * verification) read it without logging in, so it names the business and a + * contact, and no popup covers it. + */ +import { render, screen } from '@testing-library/react'; +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import HomePage from '../page'; + +vi.mock('next/link', () => ({ + default: ({ children, href, ...rest }: AnchorHTMLAttributes & { children: ReactNode }) => ( + + {children} + + ), +})); + +describe('HomePage', () => { + it('shows no coming-soon popup', () => { + render(); + + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/launch date/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('names the business and a support contact', () => { + render(); + + const about = screen.getByTestId('section-about-business'); + expect(about).toHaveTextContent('Noctusoft, Inc.'); + expect(about).toHaveTextContent('support@fieldview.live'); + expect(screen.getByTestId('link-about-support-email')).toHaveAttribute( + 'href', + 'mailto:support@fieldview.live' + ); + }); + + it('links to text messages, privacy and terms from the page body', () => { + render(); + + expect(screen.getByTestId('link-about-sms')).toHaveAttribute('href', '/sms'); + expect(screen.getByTestId('link-about-privacy')).toHaveAttribute('href', '/privacy'); + expect(screen.getByTestId('link-about-terms')).toHaveAttribute('href', '/terms'); + }); + + it('keeps owner login and get started', () => { + render(); + + expect(screen.getByTestId('link-owner-login')).toHaveAttribute('href', '/owners/login'); + expect(screen.getByTestId('link-owner-register')).toHaveAttribute('href', '/owners/register'); + }); +}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 9cce724..5abed63 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,25 +1,8 @@ -'use client'; - import Link from 'next/link'; -import { Suspense } from 'react'; - -import { ComingSoonModal } from '@/components/v2/ComingSoonModal'; -import { useWelcomeModal } from '@/hooks/useWelcomeModal'; - -function HomePageContent() { - const { isOpen, closeModal, dontShowAgain, isVeoReferral } = useWelcomeModal(); +export default function HomePage() { return ( - <> - {/* Coming Soon Modal */} - - -
+
{/* Hero Section */}
@@ -96,6 +79,46 @@ function HomePageContent() {
+ {/* About the business: public for carrier reviewers, no login needed */} +
+

+ About FieldView.Live +

+

+ FieldView.Live streams youth sports games. Teams and event owners share one link; parents and fans watch + free or buy a game. FieldView.Live is operated by Noctusoft, Inc. +

+

+ Support:{' '} + + support@fieldview.live + +

+ +
+ {/* Quick Links */}
@@ -133,14 +156,5 @@ function HomePageContent() {
- - ); -} - -export default function HomePage() { - return ( - - - ); } diff --git a/apps/web/components/v2/ComingSoonModal.tsx b/apps/web/components/v2/ComingSoonModal.tsx deleted file mode 100644 index a7dba15..0000000 --- a/apps/web/components/v2/ComingSoonModal.tsx +++ /dev/null @@ -1,212 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { TouchButton } from './primitives/TouchButton'; -import { Input } from '@/components/ui/input'; -import { apiRequest } from '@/lib/api-client'; -import { InlineError } from '@/components/v2/InlineError'; - -export interface ComingSoonModalProps { - isOpen: boolean; - onClose: () => void; - onDontShowAgain: () => void; - isVeoReferral?: boolean; -} - -export function ComingSoonModal({ - isOpen, - onClose, - onDontShowAgain, - isVeoReferral = false, -}: ComingSoonModalProps) { - const [email, setEmail] = useState(''); - const [name, setName] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState(''); - const [dontShow, setDontShow] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsSubmitting(true); - - try { - await apiRequest('/early-access/signup', { - method: 'POST', - body: JSON.stringify({ - email, - name: name || undefined, - source: isVeoReferral ? 'veo' : 'organic', - }), - }); - - setIsSuccess(true); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to sign up'); - } finally { - setIsSubmitting(false); - } - }; - - const handleClose = () => { - if (dontShow) { - onDontShowAgain(); - } else { - onClose(); - } - }; - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop with Gaussian Blur */} -
- - {/* Modal */} -
-
e.stopPropagation()} - data-testid={isSuccess ? "modal-coming-soon-success" : "modal-coming-soon"} - > - {isSuccess ? ( - // Success State -
-
🎉
-

- You're on the List! -

-

- We'll notify you as soon as we launch on{' '} - February 15, 2026. -

-

- Check your inbox for a confirmation email. -

- - Got It! - -
- ) : ( - // Main Form -
- {/* Header */} -
-
🚀
-

- Coming Soon! -

- {isVeoReferral && ( -

- Thanks for finding us through your Veo camera! 👋 -

- )} -
- - {/* Message */} -
-

- We're building something special for Veo camera owners – monetize your live streams with built-in paywalls and instant payments. -

-
- - {/* Launch Date */} -
-
- 🗓️ LAUNCH DATE -
-
- February 15, 2026 -
-
- - {/* Form */} -
-
-

- Get notified when we launch: -

- - setName(e.target.value)} - data-testid="input-name" - /> - - setEmail(e.target.value)} - required - data-testid="input-email" - /> -
- - {error && ( -
- -
- )} - - - {isSubmitting ? 'Signing Up...' : 'Notify Me'} - - - - Maybe Later - -
- - {/* Don't show again */} -
- setDontShow(e.target.checked)} - className="w-4 h-4 rounded border-[var(--fv-color-border)] text-[var(--fv-color-primary-500)] focus:ring-2 focus:ring-[var(--fv-color-primary-500)]" - data-testid="checkbox-dont-show" - /> - -
-
- )} -
-
- - ); -} diff --git a/apps/web/hooks/useWelcomeModal.ts b/apps/web/hooks/useWelcomeModal.ts deleted file mode 100644 index a0d115c..0000000 --- a/apps/web/hooks/useWelcomeModal.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * useWelcomeModal Hook - * - * Manages the Veo Discovery welcome modal state. - * Shows on first visit or when ?ref=veo query param present. - * - * localStorage key: fv_welcome_modal_shown - */ - -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; - -const STORAGE_KEY = 'fv_welcome_modal_shown'; -const DONT_SHOW_AGAIN_KEY = 'fv_welcome_modal_dont_show'; - -export interface UseWelcomeModalResult { - isOpen: boolean; - openModal: () => void; - closeModal: () => void; - dontShowAgain: () => void; - hasSeenModal: boolean; - isVeoReferral: boolean; -} - -/** - * Hook to manage the welcome modal visibility - * - * Shows the modal when: - * 1. First time visitor (not seen before) - * 2. ?ref=veo query param present (Veo camera discovery) - * - * Does NOT show when: - * 1. User checked "Don't show again" - * 2. Already shown in this session (unless ?ref=veo) - */ -export function useWelcomeModal(): UseWelcomeModalResult { - const [isOpen, setIsOpen] = useState(false); - const [hasSeenModal, setHasSeenModal] = useState(true); - const [isVeoReferral, setIsVeoReferral] = useState(false); - - const searchParams = useSearchParams(); - - useEffect(() => { - // Check if running in browser - if (typeof window === 'undefined') return; - - // Check for ?ref=veo query param (force show) - const ref = searchParams?.get('ref'); - const isVeo = ref === 'veo'; - setIsVeoReferral(isVeo); - - // Check if user opted out - const dontShow = localStorage.getItem(DONT_SHOW_AGAIN_KEY) === 'true'; - if (dontShow && !isVeo) { - setHasSeenModal(true); - return; - } - - // Check if already seen (not first visit) - const alreadySeen = localStorage.getItem(STORAGE_KEY) === 'true'; - setHasSeenModal(alreadySeen); - - // Show modal for first visit or Veo referral - if (!alreadySeen || isVeo) { - // Small delay for better UX (let page render first) - const timer = setTimeout(() => { - setIsOpen(true); - }, 500); - - return () => clearTimeout(timer); - } - }, [searchParams]); - - const openModal = useCallback(() => { - setIsOpen(true); - }, []); - - const closeModal = useCallback(() => { - setIsOpen(false); - - // Mark as seen - if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEY, 'true'); - setHasSeenModal(true); - } - }, []); - - const dontShowAgain = useCallback(() => { - setIsOpen(false); - - if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEY, 'true'); - localStorage.setItem(DONT_SHOW_AGAIN_KEY, 'true'); - setHasSeenModal(true); - } - }, []); - - return { - isOpen, - openModal, - closeModal, - dontShowAgain, - hasSeenModal, - isVeoReferral, - }; -} - -/** - * Reset welcome modal state (for testing) - */ -export function resetWelcomeModal(): void { - if (typeof window === 'undefined') return; - - localStorage.removeItem(STORAGE_KEY); - localStorage.removeItem(DONT_SHOW_AGAIN_KEY); -} From 71d40dcdabaedfcbc386a43d3b3e9d6abd3ab2c9 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Sun, 11 Oct 2026 13:13:53 -0500 Subject: [PATCH 3/3] fix(web): name Noctusoft, Inc. as the operator on every public page (#249) Twilio rejection 30482 ("Business Email Address Must Use an Official Domain"): the contact email is on noctusoft.com, which the site never named. The contact email is locked to the approved customer profile, so the site now says who operates it. "FieldView.Live is operated by Noctusoft, Inc. (https://noctusoft.com)" appears in the site footer, right under the heading of /privacy, /terms and /sms, and in the home page business section. One OperatedBy component holds the wording. Claude-Session: https://claude.ai/code/session_0183M8krzbhhBfqP9Efw7Pcb Co-authored-by: Claude Opus 5.5 --- apps/web/app/__tests__/operated-by.test.tsx | 61 +++++++++++++++++++++ apps/web/app/page.tsx | 5 +- apps/web/app/privacy/page.tsx | 3 + apps/web/app/sms/page.tsx | 2 + apps/web/app/terms/page.tsx | 3 + apps/web/components/OperatedBy.tsx | 23 ++++++++ apps/web/components/PublicLegalFooter.tsx | 3 + 7 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/__tests__/operated-by.test.tsx create mode 100644 apps/web/components/OperatedBy.tsx diff --git a/apps/web/app/__tests__/operated-by.test.tsx b/apps/web/app/__tests__/operated-by.test.tsx new file mode 100644 index 0000000..36cdbb1 --- /dev/null +++ b/apps/web/app/__tests__/operated-by.test.tsx @@ -0,0 +1,61 @@ +/** + * Carrier reviewers (Twilio toll-free verification, rejection 30482) match the + * contact email's domain to the operator, so every public policy page and the + * site footer name Noctusoft, Inc. as the operator. + */ +import { render, screen } from '@testing-library/react'; +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PublicLegalFooter } from '@/components/PublicLegalFooter'; + +import HomePage from '../page'; +import PrivacyPage from '../privacy/page'; +import SmsOptInPage from '../sms/page'; +import TermsPage from '../terms/page'; + +vi.mock('next/link', () => ({ + default: ({ children, href, ...rest }: AnchorHTMLAttributes & { children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock('@/components/SmsOptInPageForm', () => ({ + SmsOptInPageForm: () =>
, +})); + +const OPERATED_BY = 'FieldView.Live is operated by Noctusoft, Inc. (https://noctusoft.com)'; + +function expectOperatedBy(container: HTMLElement): void { + const line = container.querySelector('[data-testid="text-operated-by"]'); + expect(line).not.toBeNull(); + expect(line).toHaveTextContent(OPERATED_BY); + expect(line?.querySelector('a')).toHaveAttribute('href', 'https://noctusoft.com'); +} + +describe('operated by Noctusoft, Inc.', () => { + it('is in the site footer', () => { + render(); + expectOperatedBy(screen.getByTestId('footer-public-legal')); + }); + + it('is in the home page business section', () => { + render(); + expectOperatedBy(screen.getByTestId('section-about-business')); + }); + + it.each([ + ['privacy', PrivacyPage, 'page-privacy'], + ['terms', TermsPage, 'page-terms'], + ['sms', SmsOptInPage, 'page-sms'], + ])('is near the top of /%s', (_name, Page, testId) => { + render(); + const page = screen.getByTestId(testId); + expectOperatedBy(page); + // Right under the page heading, before the body copy. + const heading = page.querySelector('h1'); + expect(heading?.nextElementSibling).toHaveAttribute('data-testid', 'text-operated-by'); + }); +}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5abed63..a70419d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { OperatedBy } from '@/components/OperatedBy'; + export default function HomePage() { return (
@@ -90,8 +92,9 @@ export default function HomePage() {

FieldView.Live streams youth sports games. Teams and event owners share one link; parents and fans watch - free or buy a game. FieldView.Live is operated by Noctusoft, Inc. + free or buy a game.

+

Support:{' '}

Privacy Policy

+

Last updated: October 1, 2026

{SMS_BRAND} ("we", "us") operates the FieldView.Live streaming platform. This policy describes how we diff --git a/apps/web/app/sms/page.tsx b/apps/web/app/sms/page.tsx index d0b09af..0bd65b4 100644 --- a/apps/web/app/sms/page.tsx +++ b/apps/web/app/sms/page.tsx @@ -2,6 +2,7 @@ import { SMS_BRAND, SMS_SUPPORT_EMAIL } from '@fieldview/data-model'; import type { Metadata } from 'next'; import Link from 'next/link'; +import { OperatedBy } from '@/components/OperatedBy'; import { SmsOptInPageForm } from '@/components/SmsOptInPageForm'; export const metadata: Metadata = { @@ -13,6 +14,7 @@ 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, diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx index 6b5e123..56c98a7 100644 --- a/apps/web/app/terms/page.tsx +++ b/apps/web/app/terms/page.tsx @@ -1,6 +1,8 @@ import { SMS_BRAND, SMS_PURPOSE_PHRASE, SMS_SUPPORT_EMAIL } from '@fieldview/data-model'; import type { Metadata } from 'next'; +import { OperatedBy } from '@/components/OperatedBy'; + export const metadata: Metadata = { title: 'Terms of Service', description: `Terms of service for ${SMS_BRAND}`, @@ -10,6 +12,7 @@ export default function TermsPage() { return (

Terms of Service

+

Last updated: October 1, 2026

diff --git a/apps/web/components/OperatedBy.tsx b/apps/web/components/OperatedBy.tsx new file mode 100644 index 0000000..78e09ac --- /dev/null +++ b/apps/web/components/OperatedBy.tsx @@ -0,0 +1,23 @@ +const OPERATOR_NAME = 'Noctusoft, Inc.'; +const OPERATOR_URL = 'https://noctusoft.com'; + +export interface OperatedByProps { + className?: string; +} + +/** + * Names the company behind FieldView.Live. Carrier reviewers (Twilio + * toll-free verification) match the contact email's domain to this operator, + * so it appears in the site footer and at the top of each policy page. + */ +export function OperatedBy({ className }: OperatedByProps) { + return ( +

+ FieldView.Live is operated by {OPERATOR_NAME} ( + + {OPERATOR_URL} + + ) +

+ ); +} diff --git a/apps/web/components/PublicLegalFooter.tsx b/apps/web/components/PublicLegalFooter.tsx index 7683702..16279e3 100644 --- a/apps/web/components/PublicLegalFooter.tsx +++ b/apps/web/components/PublicLegalFooter.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { OperatedBy } from '@/components/OperatedBy'; + export function PublicLegalFooter() { return (
+ ); }