"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useParlay, legKey, type ParlayLeg } from "@/contexts/ParlayContext"; import { useAuth } from "@/contexts/AuthContext"; import GradeBadge from "@/components/vyndr/GradeBadge"; import SportBadge from "@/components/vyndr/SportBadge"; import ArchetypeBadge from "@/components/vyndr/ArchetypeBadge"; import { statLabel } from "@/lib/gradeAdapter"; /** * Parlay Lab (Wave 4B) — the CORRELATION BUILDER / PARLAY SLIP surface. * * The correlation MATH already lives in parlayService + /api/parlay/grade + * ParlayContext (combined grade, correlation, payout, tier-aware maxLegs). The * only thing missing was a leg source INDEPENDENT of the live slate: this page * browses tonight's PRE-GRADED props from /api/snapshot/:sport (and resolves a * player via /api/players/search), so a slip can be assembled even with no games * on screen. Legs go in via useParlay().addLeg (deduped by legKey); the slip * reads the combined grade / correlation flag / payout straight off the context. */ type Sport = ParlayLeg["sport"]; // 'NBA' | 'MLB' | 'WNBA' const SPORTS: Sport[] = ["MLB", "NBA", "WNBA"]; const TIER_MAX = { free: 2, analyst: 4, desk: 6 } as const; function tierMaxLegs(tier: string): number { return TIER_MAX[tier as keyof typeof TIER_MAX] ?? 2; } interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; stat?: string; line?: number; side?: string; grade?: string; confidence?: number; team?: string; game?: string; archetype?: string; } interface BrowseProp { player: string; stat: string; // canonical stat key (for addLeg + statLabel) line: number; direction: "over" | "under"; grade: string; confidence: number; team?: string; game?: string; archetype?: string; key: string; // legKey } function toBrowseProp(g: SnapshotGrade): BrowseProp | null { const player = String(g.player || g.player_name || "").trim(); const stat = String(g.stat_type || g.stat || "").toLowerCase().trim(); const line = typeof g.line === "number" && Number.isFinite(g.line) ? g.line : NaN; if (!player || !stat || Number.isNaN(line)) return null; const direction = String(g.side || "over").toLowerCase().startsWith("u") ? "under" : "over"; const grade = String(g.grade || "").trim(); if (!grade || grade === "—") return null; // only graded props are addable legs return { player, stat, line, direction, grade, confidence: typeof g.confidence === "number" ? g.confidence : 0, team: g.team ? String(g.team) : undefined, game: g.game ? String(g.game) : undefined, archetype: g.archetype ? String(g.archetype) : undefined, key: legKey({ player, stat, line, direction }), }; } export default function ParlayLabPage() { const { legs, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs, setMaxLegs, atCap } = useParlay(); const { tier } = useAuth(); const fullLab = tier === "desk" || tier === "analyst"; const [sport, setSport] = useState("MLB"); const [rawProps, setRawProps] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); const [suggest, setSuggest] = useState([]); // Tier -> leg cap (free 2 / analyst 4 / desk 6), same wiring as ParlayPanel. useEffect(() => { setMaxLegs(tierMaxLegs(tier || "free")); }, [tier, setMaxLegs]); // Leg source #1 — the pre-graded slate (independent of the live board). useEffect(() => { let alive = true; setLoading(true); fetch(`/api/snapshot/${sport.toLowerCase()}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (!alive) return; const grades: SnapshotGrade[] = d && Array.isArray(d.grades) ? d.grades : []; const mapped = grades.map(toBrowseProp).filter((p): p is BrowseProp => !!p); // Dedupe by legKey, keep highest confidence, sort A+ -> F then confidence. const byKey = new Map(); for (const p of mapped) { const prev = byKey.get(p.key); if (!prev || p.confidence > prev.confidence) byKey.set(p.key, p); } setRawProps(Array.from(byKey.values())); }) .catch(() => { if (alive) setRawProps([]); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [sport]); // Leg source #2 — the canonical player resolver, so search works even for a // player with no graded prop tonight (honest "no reads yet" instead of blank). useEffect(() => { const term = q.trim(); if (term.length < 2) { setSuggest([]); return; } let alive = true; const t = setTimeout(() => { fetch(`/api/players/search?sport=${sport}&q=${encodeURIComponent(term)}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (!alive) return; const players = d && Array.isArray(d.players) ? d.players : []; setSuggest(players.map((p: { full_name?: string }) => String(p.full_name || "")).filter(Boolean).slice(0, 6)); }) .catch(() => { if (alive) setSuggest([]); }); }, 220); return () => { alive = false; clearTimeout(t); }; }, [q, sport]); const filtered = useMemo(() => { const term = q.trim().toLowerCase(); const list = term ? rawProps.filter((p) => p.player.toLowerCase().includes(term)) : rawProps; return list.slice(0, 40); }, [rawProps, q]); const toggle = useCallback((p: BrowseProp) => { const existing = legs.find((l) => legKey(l) === p.key); if (existing) { removeLeg(existing.id); return; } if (atCap) { if (typeof window !== "undefined" && !fullLab && window.__goPaywall) window.__goPaywall(); return; } addLeg({ sport, player: p.player, stat: p.stat, line: p.line, direction: p.direction, grade: p.grade, confidence: p.confidence, team: p.team, game: p.game, archetype: p.archetype, }); }, [legs, removeLeg, addLeg, atCap, fullLab, sport]); const searchedButEmpty = q.trim().length >= 2 && filtered.length === 0; return (
{/* Header */}

CORRELATION BUILDER

PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD

{/* Sport tabs */}
{SPORTS.map((s) => ( ))}
{/* Search — independent of the slate; resolves via /api/players/search */} setQ(e.target.value)} placeholder="Search a player…" aria-label="Search a player" className="mono" style={{ width: "100%", padding: "10px 12px", marginBottom: 6, borderRadius: 9, fontSize: 13, background: "var(--bg-2)", border: "1px solid var(--border)", color: "var(--text-0)", }} /> {suggest.length > 0 && (
{suggest.join(" · ")}
)}
{/* LEFT — tonight's A/B reads (the leg source) */}
TONIGHT'S GRADED READS TAP TO ADD
{loading ? (
Loading tonight's reads…
) : filtered.length === 0 ? (
{searchedButEmpty ? `No graded ${sport} reads for “${q.trim()}” yet — grades post on the next snapshot.` : `No graded ${sport} reads on the board right now. Check back after the next snapshot.`}
) : (
{filtered.map((p) => { const active = hasLeg(p.key); return ( ); })}
)}
{/* RIGHT — the PARLAY SLIP (reads combined/correlation/payout from context) */}
PARLAY SLIP {legs.length} LEG{legs.length === 1 ? "" : "S"}
{/* legs */}
{legs.length === 0 ? (
No legs yet — tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other.
) : ( legs.map((l) => (
{l.archetype && } {l.player} {statLabel(l.stat)} {l.direction === "under" ? "U" : "O"}{l.line}
)) )}
{/* leg-cap notice (tier-aware) */} {legs.length >= maxLegs && (
{fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs — upgrade to Desk for 6."}
)} {/* CAUTION · CORRELATION FLAG — surfaces parlayService's warning */} {correlation?.warning && (
⚠ CAUTION · CORRELATION FLAG
{correlation.warning}
)} {/* combined / grade / stake */}
CORRELATION
0.3 ? "var(--amber)" : "var(--g-a)" }}> {legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "—"}
GRADE
{legs.length >= 2 && combined ? : {grading ? "…" : "—"}}
EST · $10
{legs.length < 2 ? ( ) : fullLab ? ( {payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "—"} ) : ( $38.50 )}
{legs.length > 0 && ( )}
); }