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/__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/__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/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/app/page.tsx b/apps/web/app/page.tsx index 9cce724..a70419d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,25 +1,10 @@ -'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(); +import { OperatedBy } from '@/components/OperatedBy'; +export default function HomePage() { return ( - <> - {/* Coming Soon Modal */} - - -
+
{/* Hero Section */}
@@ -96,6 +81,47 @@ 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. +

+ +

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

+ +
+ {/* Quick Links */}
@@ -133,14 +159,5 @@ function HomePageContent() {
- - ); -} - -export default function HomePage() { - return ( - - - ); } diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx index 87ed219..82dbf63 100644 --- a/apps/web/app/privacy/page.tsx +++ b/apps/web/app/privacy/page.tsx @@ -1,6 +1,8 @@ import { SMS_BRAND, SMS_SUPPORT_EMAIL } from '@fieldview/data-model'; import type { Metadata } from 'next'; +import { OperatedBy } from '@/components/OperatedBy'; + export const metadata: Metadata = { title: 'Privacy Policy', description: `Privacy policy for ${SMS_BRAND}`, @@ -10,6 +12,7 @@ export default function PrivacyPage() { return (

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 (
+ ); } 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); -} 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'; +}