Files
vyndr/web/src/app/layout.tsx
T
builtbykev 956a7455eb Session 38: Design system Phase G — living layer, i18n/odds, a11y, paywall, parlay math (1890 tests)
VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5
wired. Frontend-only; zero backend changes.

- lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty
  + grade->odds + combined odds (frontend; backend parlayService unchanged).
- lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the
  totals-pass-through rule (safer than the prototype's parseAm, which would
  mis-convert 228.5) + region presets.
- lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save.
- lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state).
- lib/checkout.js: checkoutUrl(plan).
- LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in
  layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the
  Preferences and Paywall modals. Nav read-meter is now a paywall trigger.

Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup);
header grew to 124px so layout paddingTop + Slate sticky-top updated to match.

18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:37:31 -04:00

156 lines
6.0 KiB
TypeScript

import type { Metadata, Viewport } from 'next';
import PostHogProvider from '@/components/PostHogProvider';
import AuthProvider from '@/contexts/AuthContext';
import ParlayProvider from '@/contexts/ParlayContext';
import ExplainModeProvider from '@/contexts/ExplainModeContext';
import Nav from '@/components/Nav';
import Footer from '@/components/Footer';
import AuthGate from '@/components/AuthGate';
import HashRedirect from '@/components/vyndr/HashRedirect';
import GlobalHosts from '@/components/vyndr/GlobalHosts';
import ParlayTray from '@/components/ParlayTray';
import BottomTabBar from '@/components/BottomTabBar';
import InstallPrompt from '@/components/InstallPrompt';
import PushPrompt from '@/components/PushPrompt';
import MFAPrompt from '@/components/MFAPrompt';
import MFAChallenge from '@/components/MFAChallenge';
import CookieConsent from '@/components/CookieConsent';
import SentryInit from '@/components/SentryInit';
import { LocaleProvider } from '@/contexts/LocaleContext';
import { headers } from 'next/headers';
import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
import './globals.css';
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
title: {
default: 'VYNDR — Sports Prop Intelligence',
template: '%s · VYNDR',
},
description:
"Grade your props across every sport with intelligence the books don't want you to have. NBA, MLB, WNBA, and soccer today — NFL and more through 2026. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.",
applicationName: 'VYNDR',
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
manifest: '/manifest.json',
keywords: [
'sports prop grading',
'NBA prop bet analysis',
'MLB prop intelligence',
'WNBA prop grading',
'soccer prop intelligence',
'World Cup 2026 props',
'xG regression analysis',
'parlay correlation analysis',
'prop betting tools',
],
openGraph: {
title: "VYNDR — Intelligence the books don't want you to have",
description:
'Read player props with Bayesian intelligence. See the factors. Know the kill conditions. Take the edge back.',
url: 'https://vyndr.app',
siteName: 'VYNDR',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'VYNDR — Sports Prop Intelligence' }],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'VYNDR',
description: 'The books have every advantage. We built this to give it back.',
images: ['/og-image.png'],
creator: '@getvyndr',
},
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/favicon.ico', sizes: '32x32' },
{ url: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
{ url: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'VYNDR',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export const viewport: Viewport = {
themeColor: '#06060B',
width: 'device-width',
initialScale: 1,
maximumScale: 5,
// Session 37 — extend under the iOS notch / home indicator so the
// mobile tab bar's env(safe-area-inset-*) padding has room to work.
viewportFit: 'cover',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Session 12 — resolve locale from the middleware-stamped request
// header so server components render with the right translations
// and the <html> dir attribute is set before paint (no FOUC of
// mis-directional text).
const hdrs = await headers();
const localeHeader = hdrs.get(LOCALE_HEADER);
const locale = isLocale(localeHeader) ? localeHeader : DEFAULT_LOCALE;
const dir = LOCALE_META[locale].dir;
// Session 13 — country from CF-IPCountry (set by middleware).
// Empty string when traffic bypasses Cloudflare (local dev, direct
// origin hits). The Africa-tier gate degrades closed on empty.
const country = hdrs.get(COUNTRY_HEADER) || '';
return (
<html lang={locale} dir={dir} className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* VYNDR 2.0 (§2): Inter for chrome/UI, JetBrains Mono for ALL data.
IBM Plex Mono + Instrument Sans kept while pages migrate session-by-session. */}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="antialiased tex-grain">
<LocaleProvider locale={locale} country={country}>
<PostHogProvider>
<AuthProvider>
<ExplainModeProvider>
<ParlayProvider>
<HashRedirect />
<Nav />
{/* Header = 60px nav + 32px ticker + 30px heartbeat (§8); offset main. */}
<AuthGate>
<main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
</AuthGate>
<Footer />
<ParlayTray />
<BottomTabBar />
<InstallPrompt />
<PushPrompt />
<MFAPrompt />
<MFAChallenge />
<CookieConsent />
<SentryInit />
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
<GlobalHosts />
</ParlayProvider>
</ExplainModeProvider>
</AuthProvider>
</PostHogProvider>
</LocaleProvider>
</body>
</html>
);
}