f1956dc953
Correlation-aware combined parlay grading — the Desk-tier differentiator.
- Correlation model (parlayService.js, added to S28 funcs): correlationScore
(game-aware 0.7/0.4/0.2/0.0), combinedGrade (avg penalized by avgCorr*0.5),
estimatedPayout (fair-odds product * (1-avgCorr) discount), correlationWarning,
gradeParlay.
- POST /api/parlay/grade (public, 2-6 legs) -> {combined,correlation,payout,legs}.
Fixed the Next proxy (was forwarding to /api/scan/parlay).
- ParlayContext: legs gained team/game/archetype; tier-aware maxLegs; auto-grades
the slip (debounced) when legs>=2 -> live combined/correlation/payout; hasLeg/
legKey/atCap.
- "+" button on every graded prop: StatStrip onAddLeg/isLegActive, wired by
vyndr/GameCard via useParlay (builds leg w/ team + game). GradeResultCard feeds
the same context from the scan page.
- ParlayPanel (replaces legacy ParlayTray): bottom slide-up w/ legs, combined
grade, correlation warning, est payout, CLEAR ALL + floating leg-count badge.
Tier-gated: free 2 legs (payout blurred -> Desk upsell), Analyst 4, Desk 6.
Backend 2185 -> 2215 tests (+30), 187 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
export interface ParlayLeg {
|
|
id: string;
|
|
sport: 'NBA' | 'MLB' | 'WNBA';
|
|
player: string;
|
|
stat: string;
|
|
line: number;
|
|
direction: 'over' | 'under';
|
|
grade: string;
|
|
confidence: number;
|
|
// Session 50 — needed for the correlation model.
|
|
team?: string;
|
|
game?: string;
|
|
archetype?: string;
|
|
}
|
|
|
|
// Session 50 — the combined analysis from /api/parlay/grade.
|
|
export interface ParlayCombined { grade: string; score: number; penalty: number }
|
|
export interface ParlayCorrelation { max: number; avg: number; warning: string | null }
|
|
export interface ParlayPayout { amount: number; multiplier: number; fairMultiplier?: number; discount?: number }
|
|
|
|
interface ParlayContextValue {
|
|
legs: ParlayLeg[];
|
|
legCount: number;
|
|
isOpen: boolean;
|
|
open: () => void;
|
|
close: () => void;
|
|
toggle: () => void;
|
|
addLeg: (leg: Omit<ParlayLeg, 'id'>) => void;
|
|
removeLeg: (id: string) => void;
|
|
clear: () => void;
|
|
// Session 50 — live combined metrics (auto-graded when legs ≥ 2).
|
|
combined: ParlayCombined | null;
|
|
correlation: ParlayCorrelation | null;
|
|
payout: ParlayPayout | null;
|
|
grading: boolean;
|
|
hasLeg: (key: string) => boolean;
|
|
// Session 50 — tier-aware leg cap (set by the panel from useAuth).
|
|
maxLegs: number;
|
|
setMaxLegs: (n: number) => void;
|
|
atCap: boolean;
|
|
}
|
|
|
|
const STORAGE_KEY = 'bbk:parlay';
|
|
const MAX_LEGS = 6; // Session 50 — Desk cap; lower tiers gated in the UI.
|
|
|
|
/** Stable de-dupe key for a leg (player|stat|line|direction). */
|
|
export function legKey(l: { player: string; stat: string; line: number; direction: string }) {
|
|
return `${l.player}|${l.stat}|${l.line}|${l.direction}`;
|
|
}
|
|
|
|
const ParlayContext = createContext<ParlayContextValue | null>(null);
|
|
|
|
export default function ParlayProvider({ children }: { children: React.ReactNode }) {
|
|
const [legs, setLegs] = useState<ParlayLeg[]>([]);
|
|
const [isOpen, setOpen] = useState(false);
|
|
// Session 50 — tier cap (default Desk 6; the panel lowers it for free/analyst).
|
|
const [maxLegs, setMaxLegs] = useState(MAX_LEGS);
|
|
const maxLegsRef = useRef(MAX_LEGS);
|
|
useEffect(() => { maxLegsRef.current = maxLegs; }, [maxLegs]);
|
|
|
|
// Restore from localStorage so a refresh doesn't drop the tray
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) setLegs(parsed);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(legs));
|
|
} catch {
|
|
/* ignore quota */
|
|
}
|
|
}, [legs]);
|
|
|
|
const addLeg = useCallback((leg: Omit<ParlayLeg, 'id'>) => {
|
|
setLegs((prev) => {
|
|
if (prev.length >= maxLegsRef.current) return prev;
|
|
// De-dupe by player+stat+line+direction
|
|
const key = `${leg.player}|${leg.stat}|${leg.line}|${leg.direction}`;
|
|
if (prev.some((p) => `${p.player}|${p.stat}|${p.line}|${p.direction}` === key)) return prev;
|
|
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
const next = [...prev, { ...leg, id }];
|
|
// Fire-and-forget: tell the backend so most-parlayed counts get bumped
|
|
void fetch('/api/parlay/add-leg', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sport: leg.sport,
|
|
player: leg.player,
|
|
stat: leg.stat,
|
|
line: leg.line,
|
|
direction: leg.direction,
|
|
}),
|
|
}).catch(() => {});
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const removeLeg = useCallback((id: string) => {
|
|
setLegs((prev) => prev.filter((l) => l.id !== id));
|
|
}, []);
|
|
|
|
const clear = useCallback(() => setLegs([]), []);
|
|
|
|
const hasLeg = useCallback((key: string) => legs.some((l) => legKey(l) === key), [legs]);
|
|
|
|
// Session 50 — auto-grade the slip whenever the legs change (≥ 2 legs).
|
|
// Debounced so rapid adds make one request; clears when below 2.
|
|
const [combined, setCombined] = useState<ParlayCombined | null>(null);
|
|
const [correlation, setCorrelation] = useState<ParlayCorrelation | null>(null);
|
|
const [payout, setPayout] = useState<ParlayPayout | null>(null);
|
|
const [grading, setGrading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (legs.length < 2) { setCombined(null); setCorrelation(null); setPayout(null); return; }
|
|
let active = true;
|
|
setGrading(true);
|
|
const t = setTimeout(() => {
|
|
fetch('/api/parlay/grade', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
legs: legs.map((l) => ({
|
|
player: l.player, team: l.team || '', game: l.game || '',
|
|
stat: l.stat, line: l.line, grade: l.grade,
|
|
})),
|
|
betAmount: 10,
|
|
}),
|
|
})
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((d) => {
|
|
if (!active || !d || d.error) return;
|
|
setCombined(d.combined ?? null);
|
|
setCorrelation(d.correlation ?? null);
|
|
setPayout(d.payout ?? null);
|
|
})
|
|
.catch(() => { /* keep last-known on failure */ })
|
|
.finally(() => { if (active) setGrading(false); });
|
|
}, 250);
|
|
return () => { active = false; clearTimeout(t); setGrading(false); };
|
|
}, [legs]);
|
|
|
|
const value = useMemo<ParlayContextValue>(() => ({
|
|
legs,
|
|
legCount: legs.length,
|
|
isOpen,
|
|
open: () => setOpen(true),
|
|
close: () => setOpen(false),
|
|
toggle: () => setOpen((o) => !o),
|
|
addLeg,
|
|
removeLeg,
|
|
clear,
|
|
combined,
|
|
correlation,
|
|
payout,
|
|
grading,
|
|
hasLeg,
|
|
maxLegs,
|
|
setMaxLegs,
|
|
atCap: legs.length >= maxLegs,
|
|
}), [legs, isOpen, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs]);
|
|
|
|
return <ParlayContext.Provider value={value}>{children}</ParlayContext.Provider>;
|
|
}
|
|
|
|
export function useParlay(): ParlayContextValue {
|
|
const ctx = useContext(ParlayContext);
|
|
if (!ctx) {
|
|
// Provide a noop fallback so components can render outside the provider
|
|
// (e.g. during prerender of marketing pages).
|
|
return {
|
|
legs: [],
|
|
legCount: 0,
|
|
isOpen: false,
|
|
open: () => {},
|
|
close: () => {},
|
|
toggle: () => {},
|
|
addLeg: () => {},
|
|
removeLeg: () => {},
|
|
clear: () => {},
|
|
combined: null,
|
|
correlation: null,
|
|
payout: null,
|
|
grading: false,
|
|
hasLeg: () => false,
|
|
maxLegs: 6,
|
|
setMaxLegs: () => {},
|
|
atCap: false,
|
|
};
|
|
}
|
|
return ctx;
|
|
}
|