Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import LineMovementChart, { Snapshot } from '@/components/LineMovementChart';
|
||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
* MoversPanel (Session 28).
|
||||
*
|
||||
* Biggest line moves for a sport — the market confirming (or contradicting)
|
||||
* a grade. Reads /api/lines/:sport/movers (Redis snapshot history, zero
|
||||
* credits). Self-hides when there's nothing moving. Free users see the top
|
||||
* 3; paid see the full board.
|
||||
*/
|
||||
|
||||
interface Mover {
|
||||
player: string;
|
||||
stat: string;
|
||||
opening: number;
|
||||
current: number;
|
||||
delta: number;
|
||||
movement: 'stable' | 'rising' | 'dropping';
|
||||
sharpSignal: boolean;
|
||||
snapshots: Snapshot[];
|
||||
}
|
||||
|
||||
export interface MoversPanelProps {
|
||||
sport: string;
|
||||
tier?: Tier;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export default function MoversPanel({ sport, tier = 'free', limit }: MoversPanelProps) {
|
||||
const [movers, setMovers] = useState<Mover[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/lines/${sport}/movers`);
|
||||
if (!res.ok) { if (!cancelled) setMovers([]); return; }
|
||||
const data = await res.json();
|
||||
if (!cancelled) setMovers(Array.isArray(data?.movers) ? data.movers : []);
|
||||
} catch {
|
||||
if (!cancelled) setMovers([]);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [sport]);
|
||||
|
||||
if (!movers || movers.length === 0) return null;
|
||||
|
||||
const tierCount = getVisibleCount(tier, movers.length);
|
||||
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
|
||||
const visible = movers.slice(0, cap);
|
||||
const hidden = limit ? movers.length - visible.length : getHiddenCount(tier, movers.length);
|
||||
|
||||
return (
|
||||
<section className="movers-panel" style={{ margin: '16px 0' }}>
|
||||
<h3 style={heading}>📈 BIGGEST MOVERS</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{visible.map((m) => (
|
||||
<div key={`${m.player}-${m.stat}`} style={row}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>{m.player}</div>
|
||||
<div style={propLine}>{m.stat.replace(/_/g, ' ')} · open {m.opening} → {m.current}</div>
|
||||
</div>
|
||||
<LineMovementChart snapshots={m.snapshots} />
|
||||
<span style={{ ...delta, color: m.delta > 0 ? '#00D4A0' : '#FF4D4D' }}>
|
||||
{m.delta > 0 ? '+' : ''}{m.delta}
|
||||
{m.sharpSignal ? <span style={sharp} title="Sharp money signal"> SHARP</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<a href="/pricing" style={upsell}>{hidden} more — upgrade for the full board →</a>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const heading: React.CSSProperties = {
|
||||
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary, #6B6B7B)', margin: '0 0 10px',
|
||||
};
|
||||
const row: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)',
|
||||
};
|
||||
const playerName: React.CSSProperties = {
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-0, #F0F0F5)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
};
|
||||
const propLine: React.CSSProperties = { fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', textTransform: 'capitalize' };
|
||||
const delta: React.CSSProperties = { flex: '0 0 auto', fontSize: 13, fontWeight: 800, whiteSpace: 'nowrap' };
|
||||
const sharp: React.CSSProperties = { fontSize: 9, color: '#FFB347', letterSpacing: '0.06em' };
|
||||
const upsell: React.CSSProperties = {
|
||||
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
|
||||
};
|
||||
Reference in New Issue
Block a user