Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Book-comparison proxy (Session 28). Forwards /api/books/* to Express
|
||||
* (best lines + per-prop book grid). Read-only, zero-credit.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
const segments = (path || []).map(encodeURIComponent).join('/');
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/books/${segments}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ bestLines: [], books: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Line-movement proxy (Session 28). Forwards /api/lines/* to Express
|
||||
* (movers + per-prop history). Read-only, zero-credit.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
const segments = (path || []).map(encodeURIComponent).join('/');
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/lines/${segments}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ movers: [], snapshots: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Parlay calculate proxy (Session 28). Forwards builder legs to Express
|
||||
* `/api/parlay/calculate` for combined odds, grade, and correlation flags.
|
||||
* Zero-credit pure math — no auth gate (the math reveals nothing private).
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/parlay/calculate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Parlay service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
* BestLinesPanel (Session 28).
|
||||
*
|
||||
* "Best lines tonight" — the prop + sportsbook offering the highest payout,
|
||||
* with the dollars-per-$100 a user saves by line-shopping. Reads
|
||||
* /api/books/:sport (cached odds, zero credits). Self-hides when empty.
|
||||
*/
|
||||
|
||||
interface BestLine {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number | null;
|
||||
bestBook: string;
|
||||
bestOdds: number;
|
||||
savings: number;
|
||||
}
|
||||
|
||||
export interface BestLinesPanelProps {
|
||||
sport: string;
|
||||
tier?: Tier;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export default function BestLinesPanel({ sport, tier = 'free', limit }: BestLinesPanelProps) {
|
||||
const [lines, setLines] = useState<BestLine[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/books/${sport}`);
|
||||
if (!res.ok) { if (!cancelled) setLines([]); return; }
|
||||
const data = await res.json();
|
||||
if (!cancelled) setLines(Array.isArray(data?.bestLines) ? data.bestLines : []);
|
||||
} catch {
|
||||
if (!cancelled) setLines([]);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [sport]);
|
||||
|
||||
if (!lines || lines.length === 0) return null;
|
||||
|
||||
const tierCount = getVisibleCount(tier, lines.length);
|
||||
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
|
||||
const visible = lines.slice(0, cap);
|
||||
const hidden = limit ? lines.length - visible.length : getHiddenCount(tier, lines.length);
|
||||
|
||||
const fmtOdds = (o: number) => (o > 0 ? `+${o}` : `${o}`);
|
||||
|
||||
return (
|
||||
<section className="best-lines-panel" style={{ margin: '16px 0' }}>
|
||||
<h3 style={heading}>💰 BEST LINES TONIGHT</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{visible.map((bl) => (
|
||||
<div key={`${bl.player}-${bl.stat}`} style={row}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>{bl.player}</div>
|
||||
<div style={propLine}>
|
||||
{bl.stat.replace(/_/g, ' ')}{bl.line != null ? ` ${bl.line}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style={book}>
|
||||
{bl.bestBook} <span style={{ color: 'var(--grade-a, #00D4A0)' }}>{fmtOdds(bl.bestOdds)}</span>
|
||||
</span>
|
||||
{bl.savings > 0 && <span style={savings}>save ${bl.savings.toFixed(2)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<a href="/pricing" style={upsell}>{hidden} more — upgrade to compare every book →</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 book: React.CSSProperties = { flex: '0 0 auto', fontSize: 12, fontWeight: 700, color: 'var(--text-0, #F0F0F5)', textTransform: 'capitalize' };
|
||||
const savings: React.CSSProperties = {
|
||||
flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 6,
|
||||
background: 'rgba(0,212,160,0.12)', color: 'var(--grade-a, #00D4A0)', whiteSpace: 'nowrap',
|
||||
};
|
||||
const upsell: React.CSSProperties = {
|
||||
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* BookComparison (Session 28).
|
||||
*
|
||||
* Presentational book-by-book grid for a single prop with the best line
|
||||
* highlighted. Fed by /api/books/:sport/:player/:stat (or the prop grid
|
||||
* from a parent). Pure render — no fetching here, so it drops cleanly into
|
||||
* a modal or an expanded prop row.
|
||||
*/
|
||||
|
||||
export interface BookRow {
|
||||
book: string;
|
||||
line?: number | null;
|
||||
over_odds?: number | null;
|
||||
under_odds?: number | null;
|
||||
isBest?: boolean;
|
||||
}
|
||||
|
||||
export interface BookComparisonProps {
|
||||
player: string;
|
||||
stat: string;
|
||||
line?: number | null;
|
||||
side?: 'over' | 'under';
|
||||
books: BookRow[];
|
||||
savings?: number;
|
||||
}
|
||||
|
||||
function fmt(o?: number | null) {
|
||||
if (o == null) return '—';
|
||||
return o > 0 ? `+${o}` : `${o}`;
|
||||
}
|
||||
|
||||
export default function BookComparison({ player, stat, line, side = 'over', books, savings }: BookComparisonProps) {
|
||||
if (!books || books.length === 0) return null;
|
||||
return (
|
||||
<div className="book-comparison" style={{ display: 'grid', gap: 8 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{books.map((b) => (
|
||||
<div
|
||||
key={b.book}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
background: b.isBest ? 'rgba(0,212,160,0.10)' : 'var(--bg-2, #12121A)',
|
||||
border: `1px solid ${b.isBest ? 'var(--grade-a, #00D4A0)' : 'var(--border, #1A1A24)'}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, textTransform: 'capitalize', color: 'var(--text-0, #F0F0F5)' }}>{b.book}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)' }}>
|
||||
{fmt(b.over_odds)} / {fmt(b.under_odds)}
|
||||
</span>
|
||||
{b.isBest ? (
|
||||
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.08em', color: '#06060B', background: 'var(--grade-a, #00D4A0)', padding: '2px 6px', borderRadius: 4 }}>BEST</span>
|
||||
) : <span />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{savings != null && savings > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}>
|
||||
Betting the best line saves ~${savings.toFixed(2)} per $100.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* LineMovementChart (Session 28).
|
||||
*
|
||||
* Dependency-free SVG sparkline of a prop's line through the day. Green
|
||||
* when the line rose, red when it dropped. Renders open + current labels.
|
||||
* Degrades to a flat midline for <2 points.
|
||||
*/
|
||||
|
||||
export interface Snapshot {
|
||||
time?: number;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface LineMovementChartProps {
|
||||
snapshots: Snapshot[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function LineMovementChart({ snapshots, width = 120, height = 32 }: LineMovementChartProps) {
|
||||
const pts = (snapshots || []).map((s) => Number(s.line)).filter((n) => Number.isFinite(n));
|
||||
if (pts.length === 0) return null;
|
||||
|
||||
const opening = pts[0];
|
||||
const current = pts[pts.length - 1];
|
||||
const delta = current - opening;
|
||||
const stroke = Math.abs(delta) < 0.5 ? 'var(--text-tertiary, #6B6B7B)' : delta > 0 ? '#00D4A0' : '#FF4D4D';
|
||||
|
||||
const min = Math.min(...pts);
|
||||
const max = Math.max(...pts);
|
||||
const range = max - min || 1;
|
||||
const pad = 4;
|
||||
const innerH = height - pad * 2;
|
||||
const stepX = pts.length > 1 ? width / (pts.length - 1) : 0;
|
||||
const yScale = (v: number) => pad + innerH - ((v - min) / range) * innerH;
|
||||
|
||||
const polyline =
|
||||
pts.length > 1
|
||||
? pts.map((v, i) => `${(i * stepX).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ')
|
||||
: `0,${(height / 2).toFixed(1)} ${width},${(height / 2).toFixed(1)}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width={width}
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={`Line moved from ${opening} to ${current}`}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<polyline points={polyline} fill="none" stroke={stroke} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{pts.length > 1 && <circle cx={width} cy={yScale(current)} r={2.5} fill={stroke} />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -11,6 +11,10 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import StatFilterPills from '@/components/StatFilterPills';
|
||||
import StreaksPanel from '@/components/StreaksPanel';
|
||||
import HotListPanel from '@/components/HotListPanel';
|
||||
// Session 28 — line-movement + book-comparison read-only panels. Both
|
||||
// self-hide when empty; free users see a top-3 teaser.
|
||||
import MoversPanel from '@/components/MoversPanel';
|
||||
import BestLinesPanel from '@/components/BestLinesPanel';
|
||||
|
||||
/**
|
||||
* The Slate (Session 13).
|
||||
@@ -762,6 +766,11 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
an off-hours slate with no warm logs simply shows the games. */}
|
||||
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
|
||||
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
|
||||
{/* Session 28 — market layers: how lines are MOVING and where the
|
||||
BEST price sits. Both read cached data (zero credits) and
|
||||
self-hide until there's something to show. */}
|
||||
<MoversPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
|
||||
<BestLinesPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
|
||||
|
||||
{/* Session 24 — removed the developer-facing "odds endpoint not
|
||||
configured yet" footer note. A sport with no data simply doesn't
|
||||
|
||||
Reference in New Issue
Block a user