Session 17: Audit response — checkout 401 fix, hero prop 404 fix, Slate parsing fix, ALL tab cascade isolation, cookie/nav/footer/autocomplete polish (1438 tests)

This commit is contained in:
Kev
2026-06-11 21:22:59 -04:00
parent 73b65a0248
commit beaf8b2a61
14 changed files with 681 additions and 25 deletions
+10 -5
View File
@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
// Session 17 — Next.js App Router refuses to compile a route that
// exports BOTH `dynamic = 'force-dynamic'` AND `revalidate`. The two
// modes are mutually exclusive: force-dynamic skips static
// generation; revalidate gates ISR. Session 16 shipped both, which
// silently broke the route at build time (production audit found a
// hard 404 on /api/hero-prop).
//
// We keep `force-dynamic` (we want the random-prop pick to vary per
// request) and emit the 15-minute cache via the response's
// Cache-Control header — Coolify's reverse proxy honors it.
export const dynamic = 'force-dynamic';
// Cache the response for 15 minutes (server-side) so cold visitors
// don't trigger a fresh grade on every page load. The cache header
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
export const revalidate = 900;
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HERO_FETCH_TIMEOUT_MS = 6000;
+6
View File
@@ -4,6 +4,11 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero';
// Session 17 — game-count strip mounted between the hero and the
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
// being graded right now" with a signup CTA. Hides itself when
// every sport returns zero (off-hours / upstream outages).
import TonightsSlate from '@/components/TonightsSlate';
import LivePropsStrip from '@/components/LivePropsStrip';
import Features from '@/components/Features';
import HowItWorks from '@/components/HowItWorks';
@@ -34,6 +39,7 @@ export default function Home() {
return (
<>
<Hero />
<TonightsSlate />
<LivePropsStrip />
<Features />
<HowItWorks />
+16
View File
@@ -340,6 +340,22 @@ export default function ScanPage() {
}}
autoComplete="off"
/>
{/* Session 17 — show "no results" when the search ran but
returned nothing. Audit reported a silent dropdown failure;
this gives the user feedback when the upstream player
service is offline or the spelling didn't match. */}
{playerQuery.trim().length >= 2 && playerSuggestions.length === 0 && playerQuery !== selectedPlayer && (
<div
className="surface-elevated"
style={{
position: 'absolute', top: '100%', left: 0, right: 0,
marginTop: 4, zIndex: 20, padding: 12,
fontSize: 12, color: 'var(--text-tertiary)',
}}
>
No {sport} players matched &ldquo;{playerQuery}&rdquo;. Check spelling or try a partial name.
</div>
)}
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
<div
className="surface-elevated"
+13
View File
@@ -2,6 +2,12 @@
import { usePathname } from 'next/navigation';
import { useParlay } from '@/contexts/ParlayContext';
// Session 17 — gate the mobile bottom nav behind authentication.
// Anonymous visitors landing on /pricing previously saw the full
// Home/Read/Parlay/Ledger/Profile bar before signing up — confusing
// surface area, and it visually overlapped the cookie-consent banner
// (both pinned to bottom: 0).
import { useAuth } from '@/contexts/AuthContext';
const TABS = [
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
@@ -17,8 +23,15 @@ const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
export default function BottomTabBar() {
const pathname = usePathname() || '/';
const { open, legCount } = useParlay();
// Session 17 — bar hidden for anonymous visitors. The bar's
// destinations (Ledger, Profile, Parlay) all require auth anyway;
// pre-auth visitors just see broken links. Authentication state
// resolves async; while `loading` is true we err on the side of
// hiding the bar to avoid a flash for signed-out users.
const { user, loading } = useAuth();
if (HIDE_ON.has(pathname)) return null;
if (loading || !user) return null;
return (
<nav
+3
View File
@@ -10,6 +10,9 @@ const LEGAL_LINKS = [
{ label: 'Terms', href: '/terms' },
{ label: 'Privacy', href: '/privacy' },
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
// Session 17 — support contact in the legal column. Audit found
// the platform had no support surface at all.
{ label: 'Support', href: 'mailto:support@vyndr.app' },
];
import Wordmark from '@/components/Wordmark';
+9 -1
View File
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Wordmark from '@/components/Wordmark';
import NotificationBell from '@/components/NotificationBell';
@@ -10,6 +11,13 @@ import { useT } from '@/contexts/LocaleContext';
export default function Nav() {
const { user, tier, scansRemaining, signOut } = useAuth();
const t = useT();
const pathname = usePathname() || '';
// Session 17 — read counter sits in the global nav, but the audit
// flagged it as noise outside the scan flow. Restrict it to /scan
// and /dashboard (where the slate-scan lives) so it acts as a
// quota indicator next to the action it gates, not a chrome pill.
const showReadCounter = pathname === '/scan' || pathname.startsWith('/scan/')
|| pathname === '/dashboard' || pathname.startsWith('/dashboard/');
const [mobileOpen, setMobileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@@ -104,7 +112,7 @@ export default function Nav() {
{user ? (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{scansRemaining != null && tier === 'free' && (
{showReadCounter && scansRemaining != null && tier === 'free' && (
<span
className="mono"
style={{
+1 -1
View File
@@ -322,7 +322,7 @@ export default function Pricing() {
</div>
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
Cancel anytime. No contracts. Card / Apple Pay / Google Pay payments processed by Stripe (test mode while we onboard founders).
Cancel anytime. No contracts. Card / Apple Pay / Google Pay payments processed by Stripe. First 100 users lock $14.99/mo Analyst for life.
</p>
</div>
+76 -9
View File
@@ -60,12 +60,26 @@ const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
soccer: ['/api/odds/soccer/wc'],
};
// Session 17 — Express `/api/odds/{sport}` returns props in the
// GROUPED shape produced by `src/routes/odds.js#groupProps`:
// { player, stat_type, home_team, away_team, game_time,
// lines: [{ book, line, over_odds, under_odds }] }
// not a flat `line`/`direction`/`book` per prop. Pre-Session 17 the
// Slate assumed flat — every prop got filtered out by the
// `Number.isFinite(r.line)` check, which is why WNBA (the only
// active sport at audit time) showed "No games published yet."
//
// RawProp now mirrors both shapes; the unwrapper below picks the
// best available line out of the `lines[]` array when present.
interface RawProp {
player?: string;
stat_type?: string;
// Flat-shape fields (pre-Session 17 contract — still tolerated)
line?: number;
direction?: 'over' | 'under';
book?: string;
// Grouped-shape fields (actual Express response since Session 7+)
lines?: Array<{ book?: string; line?: number; over_odds?: number; under_odds?: number }>;
game_time?: string;
home_team?: string;
away_team?: string;
@@ -77,6 +91,35 @@ interface OddsResponse {
error?: string;
}
// Pick the most useful single line out of a grouped prop. Preference:
// 1. A line marked `direction: over` (matches the default scan flow)
// 2. The first numeric line in the array
// 3. The flat-shape `line` field if present (legacy callers)
function pickLine(r: RawProp): { line: number; direction: 'over' | 'under'; book: string } | null {
// Flat shape wins when present — preserves the older test fixtures.
if (Number.isFinite(r.line)) {
return {
line: r.line as number,
direction: (r.direction as 'over' | 'under') || 'over',
book: r.book || 'draftkings',
};
}
if (Array.isArray(r.lines)) {
const first = r.lines.find((l) => Number.isFinite(l.line));
if (first && Number.isFinite(first.line)) {
// The grouped response doesn't carry a per-line direction —
// each line has both over/under odds. Default to `over` since
// that's the default scan direction.
return {
line: first.line as number,
direction: 'over',
book: first.book || 'draftkings',
};
}
}
return null;
}
interface SlateGame {
sport: SlateSport;
homeTeam: string;
@@ -90,7 +133,10 @@ interface SlateGame {
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
const games = new Map<string, SlateGame>();
for (const r of rawProps) {
if (!r.player || !r.stat_type || r.line == null) continue;
if (!r.player || !r.stat_type) continue;
// Session 17 — unwrap the grouped `lines[]` shape from Express.
const lineInfo = pickLine(r);
if (!lineInfo) continue;
const home = r.home_team || '?';
const away = r.away_team || '?';
const time = r.game_time || '';
@@ -107,9 +153,9 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
games.get(key)!.props.push({
player: r.player,
stat_type: r.stat_type,
line: Number(r.line),
direction: (r.direction as PropRowProp['direction']) || 'over',
book: r.book,
line: lineInfo.line,
direction: lineInfo.direction,
book: lineInfo.book,
});
}
// Sort each game's props by player + stat for stable rendering.
@@ -183,24 +229,45 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
return { sport, body };
})
.catch((err) => {
// Re-throw so allSettled catches it, but attach the
// sport so the per-sport error-tracking below can
// surface "Soccer odds unavailable" without blanking
// the rest of the slate.
const e = err instanceof Error ? err : new Error(String(err));
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport;
throw e;
})
),
),
);
const allGames: SlateGame[] = [];
let firstError: string | null = null;
const failedSports: SlateSport[] = [];
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport));
const sportsThatSucceeded = new Set<SlateSport>();
for (const r of results) {
if (r.status === 'fulfilled') {
sportsThatSucceeded.add(r.value.sport);
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
allGames.push(...grouped);
} else if (!firstError) {
firstError = r.reason instanceof Error ? r.reason.message : 'Odds fetch failed';
} else {
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
}
}
setGames(allGames);
setUnsupportedSports(unsupported);
if (allGames.length === 0 && firstError) setFetchError(firstError);
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
// Session 17 — only surface a top-level error when EVERY sport
// attempted in this tab failed. Partial successes (NBA ok,
// soccer 503) silently drop the failed sport's row and surface
// it via the existing "endpoint not configured" footer note.
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) {
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
}
setLoading(false);
}, []);
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Tonight's Slate — anonymous-landing game-count strip (Session 17).
*
* Client component (the landing page is itself a client component
* because it uses `useAuth` for the post-signin redirect). Fetches
* game counts for NBA / WNBA / MLB through the existing Next.js
* proxies on mount, dedupes by (away, home, time) triple, renders
* "X NBA · Y WNBA · Z MLB games being graded right now." with a
* signup CTA.
*
* Hides itself entirely when every sport returns zero — better
* silent than misleading. The counts come from the same proxies The
* Slate uses, so post-signup the user sees the same numbers.
*
* Co-exists with `LivePropsStrip` (which shows a few actual graded
* props). This component is the executive-summary line above it.
*/
interface OddsResponse {
sport?: string;
props?: Array<{ home_team?: string; away_team?: string; game_time?: string }>;
}
const FETCH_TIMEOUT_MS = 4000;
async function countGamesForSport(sport: string): Promise<number> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(`/api/odds/${sport}`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) return 0;
const body = (await res.json().catch(() => null)) as OddsResponse | null;
if (!body || !Array.isArray(body.props)) return 0;
const games = new Set<string>();
for (const p of body.props) {
if (!p.home_team || !p.away_team) continue;
games.add(`${p.away_team}__${p.home_team}__${p.game_time || ''}`);
}
return games.size;
} catch {
return 0;
} finally {
clearTimeout(timer);
}
}
export default function TonightsSlate() {
const [counts, setCounts] = useState<{ nba: number; wnba: number; mlb: number } | null>(null);
useEffect(() => {
let alive = true;
(async () => {
const [nba, wnba, mlb] = await Promise.all([
countGamesForSport('nba'),
countGamesForSport('wnba'),
countGamesForSport('mlb'),
]);
if (alive) setCounts({ nba, wnba, mlb });
})();
return () => { alive = false; };
}, []);
if (!counts) return null;
const total = counts.nba + counts.wnba + counts.mlb;
if (total === 0) return null;
const segments: string[] = [];
if (counts.nba > 0) segments.push(`${counts.nba} NBA`);
if (counts.wnba > 0) segments.push(`${counts.wnba} WNBA`);
if (counts.mlb > 0) segments.push(`${counts.mlb} MLB`);
return (
<section
style={{
padding: '20px 24px',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-surface)',
}}
aria-label="Tonight's slate summary"
>
<div
style={{
maxWidth: 1100,
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
}}
>
<div>
<p
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: 4,
}}
>
Tonight&apos;s Slate
</p>
<p style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>
{segments.join(' · ')} games being graded right now.
</p>
</div>
<a
href="/signup"
className="btn-primary"
style={{
padding: '10px 18px',
fontSize: 13,
fontWeight: 700,
textDecoration: 'none',
}}
>
Sign up to grade these
</a>
</div>
</section>
);
}