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:
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 “{playerQuery}”. Check spelling or try a partial name.
|
||||
</div>
|
||||
)}
|
||||
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
||||
<div
|
||||
className="surface-elevated"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user