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
7 changes: 7 additions & 0 deletions apps/api/__tests__/integration/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
45 changes: 45 additions & 0 deletions apps/api/src/lib/__tests__/health-fleet.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof COMMIT_KEYS)[number], string | undefined>> = {};

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');
});
});
30 changes: 30 additions & 0 deletions apps/api/src/lib/health-fleet.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
2 changes: 2 additions & 0 deletions apps/api/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -90,6 +91,7 @@ export function createHealthRouter(): Router {
timestamp: new Date().toISOString(),
checks,
email: checkEmailProvider(),
...buildFleetHealthFields(isHealthy, 'fieldview-api'),
});
});

Expand Down
55 changes: 55 additions & 0 deletions apps/web/app/__tests__/home-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement> & { children: ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

describe('HomePage', () => {
it('shows no coming-soon popup', () => {
render(<HomePage />);

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(<HomePage />);

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(<HomePage />);

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(<HomePage />);

expect(screen.getByTestId('link-owner-login')).toHaveAttribute('href', '/owners/login');
expect(screen.getByTestId('link-owner-register')).toHaveAttribute('href', '/owners/register');
});
});
61 changes: 61 additions & 0 deletions apps/web/app/__tests__/operated-by.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement> & { children: ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

vi.mock('@/components/SmsOptInPageForm', () => ({
SmsOptInPageForm: () => <form data-testid="form-sms-opt-in" />,
}));

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(<PublicLegalFooter />);
expectOperatedBy(screen.getByTestId('footer-public-legal'));
});

it('is in the home page business section', () => {
render(<HomePage />);
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(<Page />);
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');
});
});
13 changes: 13 additions & 0 deletions apps/web/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';

import { resolveHealthCommit, resolveHealthEnv } from '@/lib/health-fleet';

export async function GET(): Promise<NextResponse> {
return NextResponse.json({
ok: true,
service: 'fieldview-web',
commit: resolveHealthCommit(),
env: resolveHealthEnv(),
utc: new Date().toISOString(),
});
}
71 changes: 44 additions & 27 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 */}
<ComingSoonModal
isOpen={isOpen}
onClose={closeModal}
onDontShowAgain={dontShowAgain}
isVeoReferral={isVeoReferral}
/>

<div className="min-h-screen flex flex-col" data-testid="container-home">
<div className="min-h-screen flex flex-col" data-testid="container-home">
{/* Hero Section */}
<main className="flex-1 flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-24 gradient-primary">
<div className="text-center max-w-3xl mx-auto space-y-6 sm:space-y-8">
Expand Down Expand Up @@ -96,6 +81,47 @@ function HomePageContent() {
</div>
</div>

{/* About the business: public for carrier reviewers, no login needed */}
<section
className="max-w-2xl mx-auto pt-6 sm:pt-8 space-y-2 text-sm sm:text-base text-muted-foreground"
aria-labelledby="heading-about-business"
data-testid="section-about-business"
>
<h2 id="heading-about-business" className="font-semibold text-base sm:text-lg text-foreground">
About FieldView.Live
</h2>
<p>
FieldView.Live streams youth sports games. Teams and event owners share one link; parents and fans watch
free or buy a game.
</p>
<OperatedBy />
<p>
Support:{' '}
<a
href="mailto:support@fieldview.live"
className="underline underline-offset-4 hover:text-foreground"
data-testid="link-about-support-email"
>
support@fieldview.live
</a>
</p>
<nav className="flex flex-wrap gap-4 justify-center" aria-label="Policies">
<Link href="/sms" className="underline underline-offset-4 hover:text-foreground" data-testid="link-about-sms">
Text messages
</Link>
<Link
href="/privacy"
className="underline underline-offset-4 hover:text-foreground"
data-testid="link-about-privacy"
>
Privacy Policy
</Link>
<Link href="/terms" className="underline underline-offset-4 hover:text-foreground" data-testid="link-about-terms">
Terms of Service
</Link>
</nav>
</section>

{/* Quick Links */}
<div className="pt-6 sm:pt-8 text-sm text-muted-foreground">
<div className="flex flex-col sm:flex-row gap-3 justify-center">
Expand Down Expand Up @@ -133,14 +159,5 @@ function HomePageContent() {
</div>
</footer>
</div>
</>
);
}

export default function HomePage() {
return (
<Suspense fallback={null}>
<HomePageContent />
</Suspense>
);
}
3 changes: 3 additions & 0 deletions apps/web/app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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}`,
Expand All @@ -10,6 +12,7 @@ export default function PrivacyPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10 prose prose-neutral dark:prose-invert" data-testid="page-privacy">
<h1>Privacy Policy</h1>
<OperatedBy />
<p>Last updated: October 1, 2026</p>
<p>
{SMS_BRAND} (&quot;we&quot;, &quot;us&quot;) operates the FieldView.Live streaming platform. This policy describes how we
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/sms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -13,6 +14,7 @@ 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>
<OperatedBy className="mt-2 text-sm text-muted-foreground" />
<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,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -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}`,
Expand All @@ -10,6 +12,7 @@ export default function TermsPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10 prose prose-neutral dark:prose-invert" data-testid="page-terms">
<h1>Terms of Service</h1>
<OperatedBy />
<p>Last updated: October 1, 2026</p>

<section id="sms" data-testid="section-terms-sms">
Expand Down
Loading
Loading