Files
vyndr/web/src/components/ParlayTray.tsx
T

232 lines
7.5 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext';
import { GradePill } from './GradeCard';
import { trackParlayBuilt } from '@/lib/analytics';
interface ParlayGradeResponse {
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
decimal_odds?: number;
}
export default function ParlayTray() {
const { legs, isOpen, close, removeLeg, clear } = useParlay();
const [grading, setGrading] = useState(false);
const [parlayResult, setParlayResult] = useState<ParlayGradeResponse | null>(null);
// Reset the parlay grade whenever the leg set changes
useEffect(() => {
setParlayResult(null);
}, [legs]);
const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]);
const gradeParlay = async () => {
if (legs.length < 2) return;
setGrading(true);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/parlay/grade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
legs: legs.map((l) => ({
sport: l.sport,
player: l.player,
stat_type: l.stat,
line: l.line,
direction: l.direction,
})),
}),
});
const data = (await res.json()) as ParlayGradeResponse;
if (res.ok) {
setParlayResult(data);
trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade });
}
} finally {
setGrading(false);
}
};
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Parlay tray"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
>
<button
aria-label="Close parlay tray"
onClick={close}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
border: 'none',
cursor: 'pointer',
}}
/>
<section
className="surface-elevated diagonal-cut animate-fade-up"
style={{
position: 'relative',
width: '100%',
maxWidth: 560,
maxHeight: '85vh',
margin: '0 auto',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
overflowY: 'auto',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Parlay tray</h2>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
{legs.length} LEG{legs.length === 1 ? '' : 'S'} · {sports.join(' · ') || 'ADD A LEG'}
</p>
</div>
<button onClick={close} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 12 }}>
Close
</button>
</header>
{legs.length === 0 ? (
<EmptyTrayCopy />
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{legs.map((l) => (
<LegRow key={l.id} leg={l} onRemove={() => removeLeg(l.id)} />
))}
</ul>
)}
{parlayResult && (
<div
className="surface diagonal-cut"
style={{
padding: 16,
textAlign: 'center',
border: '1px solid var(--border-focus)',
}}
>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
PARLAY GRADE
</p>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
<GradePill grade={parlayResult.parlay_grade} confidence={parlayResult.parlay_confidence} />
</div>
{parlayResult.correlation_flags.length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
textAlign: 'left',
borderRadius: 8,
background: 'rgba(255,179,71,0.10)',
border: '1px solid rgba(255,179,71,0.30)',
}}
>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--grade-c)', marginBottom: 4 }}>
CORRELATION WARNINGS
</p>
{parlayResult.correlation_flags.map((f, i) => (
<p key={i} style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
{f.detail}
</p>
))}
</div>
)}
</div>
)}
{legs.length > 0 && (
<footer style={{ display: 'grid', gap: 8 }}>
<button
onClick={gradeParlay}
disabled={legs.length < 2 || grading}
className={grading ? 'shimmer-loading' : 'btn-primary'}
style={{ padding: 14, fontWeight: 600, fontSize: 14, border: 'none', borderRadius: 12, color: 'var(--text-primary)', cursor: legs.length < 2 ? 'not-allowed' : 'pointer', opacity: legs.length < 2 ? 0.4 : 1 }}
>
{grading ? 'Running correlation analysis…' : legs.length < 2 ? 'Add 2+ legs to grade' : 'Grade parlay'}
</button>
<button onClick={clear} className="btn-ghost" style={{ padding: 12, fontSize: 13 }}>
Clear tray
</button>
</footer>
)}
</section>
</div>
);
}
function EmptyTrayCopy() {
return (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
NO LEGS YET
</p>
<p style={{ marginTop: 12, color: 'var(--text-secondary)', fontSize: 14, lineHeight: 1.6 }}>
Read a prop, hit <strong>Add to Parlay</strong>, and we&apos;ll build the slip here.
We grade overall correlation and surface the legs that secretly fight each other.
</p>
</div>
);
}
function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) {
return (
<li
className="surface"
style={{
padding: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{leg.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<GradePill grade={leg.grade} />
<button
onClick={onRemove}
aria-label={`Remove ${leg.player}`}
className="btn-ghost"
style={{ padding: '4px 10px', fontSize: 11 }}
>
</button>
</div>
</li>
);
}