Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
* StreaksPanel (Session 23).
|
||||
*
|
||||
* Surfaces computed player streaks for a sport, narrowed by the active
|
||||
* stat filter. Everything through VYNDR's lens — "4-game 28+ scoring
|
||||
* streak", not "31.2 PPG". Free users see the top 3 with an upgrade
|
||||
* nudge; paid users see the full list.
|
||||
*
|
||||
* Self-hides when there are no streaks so the landing page never shows an
|
||||
* empty box — the other layers (schedule, game lines, props) carry the
|
||||
* slate when no logs are warm yet.
|
||||
*/
|
||||
|
||||
interface Streak {
|
||||
player: string;
|
||||
playerId: string | number | null;
|
||||
team: string | null;
|
||||
type: string;
|
||||
category: string;
|
||||
currentStreak: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface StreaksPanelProps {
|
||||
sport: string;
|
||||
tier?: Tier;
|
||||
stat?: string;
|
||||
/** Optional hard cap (teaser usage on the landing page). */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit }: StreaksPanelProps) {
|
||||
const [streaks, setStreaks] = useState<Streak[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/streaks/${sport}?stat=${encodeURIComponent(stat)}`);
|
||||
if (!res.ok) { if (!cancelled) setStreaks([]); return; }
|
||||
const data = await res.json();
|
||||
if (!cancelled) setStreaks(Array.isArray(data?.streaks) ? data.streaks : []);
|
||||
} catch {
|
||||
if (!cancelled) setStreaks([]);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [sport, stat]);
|
||||
|
||||
if (!streaks || streaks.length === 0) return null;
|
||||
|
||||
const tierCount = getVisibleCount(tier, streaks.length);
|
||||
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
|
||||
const visible = streaks.slice(0, cap);
|
||||
const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length);
|
||||
|
||||
return (
|
||||
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
||||
<h3 style={panelHeading}>🔥 STREAKS</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{visible.map((s) => (
|
||||
<div key={`${s.player}-${s.type}`} style={rowStyle}>
|
||||
<img
|
||||
src={getHeadshotUrl({ sport, playerId: s.playerId })}
|
||||
alt={s.player}
|
||||
width={36}
|
||||
height={36}
|
||||
style={avatarStyle}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>{s.player}{s.team ? ` · ${s.team}` : ''}</div>
|
||||
<div style={streakDesc}>{s.description}</div>
|
||||
</div>
|
||||
<span style={badgeStyle}>{s.currentStreak} G</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<a href="/pricing" style={upsellStyle}>
|
||||
{hidden} more streak{hidden === 1 ? '' : 's'} — upgrade to see all →
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const panelHeading: React.CSSProperties = {
|
||||
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
|
||||
};
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
|
||||
};
|
||||
const avatarStyle: React.CSSProperties = {
|
||||
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
|
||||
};
|
||||
const playerName: React.CSSProperties = {
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
};
|
||||
const streakDesc: React.CSSProperties = {
|
||||
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
|
||||
};
|
||||
const badgeStyle: React.CSSProperties = {
|
||||
flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px',
|
||||
borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)',
|
||||
};
|
||||
const upsellStyle: React.CSSProperties = {
|
||||
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--accent, #E94B3C)', textDecoration: 'none',
|
||||
};
|
||||
Reference in New Issue
Block a user