Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
|
||||
|
||||
/**
|
||||
* GameCard — one game in the Slate (Session 13). Header with teams +
|
||||
* time + venue + sport emoji; expandable list of player props
|
||||
* underneath, each a PropRow.
|
||||
*
|
||||
* State minimalism: this component only manages "show more props"
|
||||
* expansion. The graded-props Map and the "is this prop loading right
|
||||
* now" boolean both live on the Slate (one source of truth for the
|
||||
* grading queue).
|
||||
*/
|
||||
|
||||
export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer';
|
||||
|
||||
const SPORT_EMOJI: Record<SlateSport, string> = {
|
||||
nba: '🏀',
|
||||
wnba: '🏀',
|
||||
mlb: '⚾',
|
||||
soccer: '⚽',
|
||||
};
|
||||
|
||||
const SPORT_ACCENT: Record<SlateSport, string> = {
|
||||
nba: '#E94B3C',
|
||||
wnba: '#FFB347',
|
||||
mlb: '#1E90FF',
|
||||
soccer: '#00D4A0',
|
||||
};
|
||||
|
||||
export interface GameCardProps {
|
||||
sport: SlateSport;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
gameTime?: string; // ISO timestamp — empty when status is unknown
|
||||
venue?: string;
|
||||
context?: string; // 'Group A · Matchday 1', 'Game 4', etc.
|
||||
props: PropRowProp[];
|
||||
gradedProps: Map<string, PropRowResult>;
|
||||
loadingKey?: string | null; // propRowKey of the prop currently grading
|
||||
errorByKey?: Record<string, string | undefined>;
|
||||
tier?: Tier;
|
||||
onGrade: (prop: PropRowProp) => void;
|
||||
onUpgrade?: () => void;
|
||||
defaultVisible?: number; // how many props to show before "+ N more"
|
||||
}
|
||||
|
||||
function formatTime(iso?: string) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: 'short', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function GameCard(props: GameCardProps) {
|
||||
const {
|
||||
sport, homeTeam, awayTeam, gameTime, venue, context,
|
||||
props: propList, gradedProps, loadingKey, errorByKey,
|
||||
tier = 'free', onGrade, onUpgrade,
|
||||
defaultVisible = 4,
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const visibleProps = expanded ? propList : propList.slice(0, defaultVisible);
|
||||
const hiddenCount = propList.length - visibleProps.length;
|
||||
const accent = SPORT_ACCENT[sport];
|
||||
|
||||
return (
|
||||
<article
|
||||
className="surface"
|
||||
style={{
|
||||
background: 'var(--bg-2, #12121A)',
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
letterSpacing: '-0.01em',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{awayTeam}
|
||||
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', margin: '0 8px', fontWeight: 400 }}>
|
||||
@
|
||||
</span>
|
||||
{homeTeam}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 999,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{propList.length} prop{propList.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{propList.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
padding: '20px 16px',
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Props for this game aren't published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{visibleProps.map((p) => {
|
||||
const key = propRowKey(p);
|
||||
return (
|
||||
<PropRow
|
||||
key={key}
|
||||
prop={p}
|
||||
result={gradedProps.get(key) ?? null}
|
||||
loading={loadingKey === key}
|
||||
error={errorByKey?.[key] ?? null}
|
||||
tier={tier}
|
||||
onRead={onGrade}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<li
|
||||
style={{
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
padding: '10px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--grade-a, #00D4A0)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ {hiddenCount} more prop{hiddenCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,12 @@ export default function Nav() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Session 12 — translation labels resolved at render time so a
|
||||
// locale switch flips the nav without a code change. Hrefs stay
|
||||
// English (the [locale]/ refactor is a future session).
|
||||
// locale switch flips the nav without a code change.
|
||||
// Session 13 — "Scan" removed from the primary nav: The Slate on
|
||||
// /dashboard IS the scan surface (click [Read] on any prop). The
|
||||
// /scan page still exists as a fallback for custom props and is
|
||||
// reachable from the slate's "Scan manually" empty-state CTA.
|
||||
const NAV_LINKS = [
|
||||
{ label: t('nav.scan'), href: '/scan' },
|
||||
{ label: t('nav.tracker'), href: '/tracker' },
|
||||
{ label: t('nav.ledger'), href: '/ledger' },
|
||||
{ label: t('nav.pricing'), href: '/pricing' },
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useT, useLocale } from '@/contexts/LocaleContext';
|
||||
import { AFRICA_LOCALES } from '@/lib/locales';
|
||||
import { useT, useRegion } from '@/contexts/LocaleContext';
|
||||
|
||||
type TierId = 'free' | 'africa' | 'analyst' | 'desk';
|
||||
|
||||
@@ -113,21 +112,28 @@ const TIERS: TierConfig[] = [
|
||||
export default function Pricing() {
|
||||
const router = useRouter();
|
||||
const { session, loading: authLoading } = useAuth();
|
||||
const { locale } = useLocale();
|
||||
const { inAfrica } = useRegion();
|
||||
const t = useT();
|
||||
const [pending, setPending] = useState<TierId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Session 12 — Africa-language users see VYNDR Africa first. The
|
||||
// tier order is stable per locale (no flicker between renders).
|
||||
// Browser region (NG / KE / ZA / GH) isn't available server-side
|
||||
// without IP geolocation, so we use the locale as a proxy. Users
|
||||
// outside the locale set can still pick the Africa tier; it just
|
||||
// doesn't lead the card grid for them.
|
||||
const orderedTiers = AFRICA_LOCALES.has(locale)
|
||||
// Session 13 — Africa tier visibility + order is now driven by
|
||||
// REAL IP geolocation via Cloudflare's CF-IPCountry header (stamped
|
||||
// onto x-vyndr-country by the middleware). The previous locale-
|
||||
// based proxy (Swahili speakers everywhere) was both too narrow
|
||||
// (most African users browse in English/French) and too broad
|
||||
// (Swahili speakers outside Africa got the discount).
|
||||
//
|
||||
// Inside Africa: VYNDR Africa renders first, then Free, then Analyst, Desk.
|
||||
// Outside Africa: the Africa tier card is filtered out of the render
|
||||
// entirely — no path for non-African users to even
|
||||
// see the $4.99 option.
|
||||
// Unknown country (local dev, non-Cloudflare): degrades closed →
|
||||
// Africa tier hidden (same as outside Africa).
|
||||
const orderedTiers = inAfrica
|
||||
? [TIERS.find((x) => x.id === 'africa')!, TIERS.find((x) => x.id === 'free')!,
|
||||
TIERS.find((x) => x.id === 'analyst')!, TIERS.find((x) => x.id === 'desk')!]
|
||||
: TIERS;
|
||||
: TIERS.filter((x) => x.id !== 'africa');
|
||||
|
||||
async function startCheckout(tier: TierId) {
|
||||
setError(null);
|
||||
@@ -218,7 +224,18 @@ export default function Pricing() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
|
||||
<div
|
||||
className="pricing-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 24,
|
||||
// The desktop column count tracks the visible tier count
|
||||
// (3 outside Africa, 4 inside). styled-jsx's `:global()`
|
||||
// doesn't handle attribute selectors cleanly, so we pin
|
||||
// the value via a CSS custom property on the grid root.
|
||||
['--pricing-cols' as keyof React.CSSProperties]: String(orderedTiers.length),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{orderedTiers.map((tier, i) => {
|
||||
const isPending = pending === tier.id;
|
||||
const isDisabled = authLoading || (pending !== null && !isPending);
|
||||
@@ -318,15 +335,15 @@ export default function Pricing() {
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:global(.pricing-grid) {
|
||||
/* Session 12 — Africa tier brings the count to 4. On
|
||||
tablet we stay 2-up so cards don't squeeze; desktop
|
||||
unfolds to 4-up at >=1100px. */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
:global(.pricing-grid) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
/* --pricing-cols is set by the React render (3 outside
|
||||
Africa, 4 inside) so the desktop layout tracks the
|
||||
visible tier count without an attribute selector. */
|
||||
grid-template-columns: repeat(var(--pricing-cols, 3), 1fr);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* PropRow — single prop line in the Slate (Session 13).
|
||||
*
|
||||
* Three visual states:
|
||||
* 1. Ungraded — player | stat | line | book | [Read]
|
||||
* 2. Grading — player | stat | line | book | […] (busy)
|
||||
* 3. Graded — player | stat | line | book | grade | ▸ (expandable)
|
||||
*
|
||||
* Pure presentational. The parent owns the grading API call (one
|
||||
* shared call site = consistent rate-limit + error handling). PropRow
|
||||
* just emits onRead() and reads the supplied state.
|
||||
*/
|
||||
|
||||
export type PropDirection = 'over' | 'under';
|
||||
export type Tier = 'free' | 'africa' | 'analyst' | 'desk';
|
||||
|
||||
export interface PropRowProp {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: PropDirection;
|
||||
book?: string;
|
||||
// Stable key used by the parent to look up grade results.
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface KillCondition {
|
||||
code: string;
|
||||
reason: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface PropRowResult {
|
||||
grade: string; // 'A', 'B', etc.
|
||||
confidence?: number;
|
||||
edge_pct?: number;
|
||||
reasoning?: { summary?: string; steps?: unknown; locked?: boolean };
|
||||
kill_conditions_triggered?: KillCondition[];
|
||||
tier_gated?: boolean;
|
||||
upgrade_hint?: string;
|
||||
}
|
||||
|
||||
export interface PropRowProps {
|
||||
prop: PropRowProp;
|
||||
result?: PropRowResult | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
tier?: Tier;
|
||||
onRead: (prop: PropRowProp) => void;
|
||||
onUpgrade?: () => void;
|
||||
}
|
||||
|
||||
const STAT_LABELS: Record<string, string> = {
|
||||
goals: 'Goals',
|
||||
assists: 'Assists',
|
||||
shots_on_target: 'SoT',
|
||||
shots: 'Shots',
|
||||
tackles: 'Tackles',
|
||||
cards: 'Cards',
|
||||
corners: 'Corners',
|
||||
saves: 'Saves',
|
||||
passes: 'Passes',
|
||||
clean_sheet: 'Clean Sheet',
|
||||
points: 'Pts',
|
||||
rebounds: 'Reb',
|
||||
threes: '3PT',
|
||||
blocks: 'Blk',
|
||||
steals: 'Stl',
|
||||
pra: 'P+R+A',
|
||||
turnovers: 'TO',
|
||||
strikeouts: 'K',
|
||||
hits: 'H',
|
||||
home_runs: 'HR',
|
||||
rbi: 'RBI',
|
||||
runs: 'R',
|
||||
total_bases: 'TB',
|
||||
earned_runs: 'ER',
|
||||
innings_pitched: 'IP',
|
||||
};
|
||||
|
||||
const BOOK_COLORS: Record<string, string> = {
|
||||
draftkings: '#53D337',
|
||||
fanduel: '#1493FF',
|
||||
betmgm: '#BB9959',
|
||||
caesars: '#C8A35F',
|
||||
pointsbet: '#E2231A',
|
||||
};
|
||||
|
||||
const BOOK_LABELS: Record<string, string> = {
|
||||
draftkings: 'DK',
|
||||
fanduel: 'FD',
|
||||
betmgm: 'MGM',
|
||||
caesars: 'CSR',
|
||||
pointsbet: 'PB',
|
||||
};
|
||||
|
||||
function gradeColor(grade?: string): string {
|
||||
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||
if (g === 'A') return 'var(--grade-a, #00D4A0)';
|
||||
if (g === 'B') return 'var(--grade-b, #4ECDC4)';
|
||||
if (g === 'C') return 'var(--grade-c, #FFD93D)';
|
||||
return 'var(--grade-d, #FF6B6B)';
|
||||
}
|
||||
|
||||
export default function PropRow(props: PropRowProps) {
|
||||
const { prop, result, loading, error, tier = 'free', onRead, onUpgrade } = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isGraded = !!result;
|
||||
const isLocked = !!(result?.tier_gated || result?.reasoning?.locked);
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
padding: '12px 14px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 240,
|
||||
}}
|
||||
>
|
||||
{prop.player}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{STAT_LABELS[prop.stat_type] || prop.stat_type}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{prop.direction === 'under' ? 'u' : 'o'}{prop.line.toFixed(1)}
|
||||
</span>
|
||||
{prop.book && (
|
||||
<span
|
||||
className="mono"
|
||||
aria-label={`Book: ${prop.book}`}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: BOOK_COLORS[prop.book] || 'var(--text-tertiary, #6B6B7B)',
|
||||
}}
|
||||
/>
|
||||
{BOOK_LABELS[prop.book] || prop.book.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{!isGraded && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(prop)}
|
||||
className="btn-ghost"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
border: '1px solid var(--grade-a, #00D4A0)',
|
||||
color: 'var(--grade-a, #00D4A0)',
|
||||
background: 'transparent',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
)}
|
||||
{loading && (
|
||||
<span
|
||||
className="mono"
|
||||
aria-label="Grading"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
padding: '4px 12px',
|
||||
}}
|
||||
>
|
||||
…
|
||||
</span>
|
||||
)}
|
||||
{isGraded && result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Grade ${result.grade} — ${expanded ? 'collapse' : 'expand'} reasoning`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 10px',
|
||||
border: `1px solid ${gradeColor(result.grade)}`,
|
||||
borderRadius: 4,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
color: gradeColor(result.grade),
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 14, fontWeight: 800, letterSpacing: '-0.02em' }}>
|
||||
{result.grade}
|
||||
</span>
|
||||
{typeof result.confidence === 'number' && (
|
||||
<span className="mono" style={{ fontSize: 10, opacity: 0.7 }}>
|
||||
{result.confidence.toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden style={{ fontSize: 10 }}>{expanded ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
fontSize: 12,
|
||||
color: 'var(--grade-d, #FF6B6B)',
|
||||
paddingTop: 6,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && result && (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '12px 0 4px',
|
||||
borderTop: '1px dashed var(--border, #1A1A24)',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{isLocked ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
border: '1px dashed var(--border, #1A1A24)',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(0,0,0,0.20)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
aria-hidden
|
||||
style={{
|
||||
filter: 'blur(4px)',
|
||||
userSelect: 'none',
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Recent form: 28.4 over last 5 · Opp defense: top-5 vs PG ·
|
||||
Pace: +3.1 possessions · Usage: 31% · Trap composite: 0.18
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', marginBottom: 10 }}>
|
||||
{result.upgrade_hint || 'Unlock the reasoning — factor analysis, kill conditions, and trap score.'}
|
||||
</p>
|
||||
{tier === 'free' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Unlock full analysis
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/pricing" style={{ color: 'var(--grade-a, #00D4A0)', fontSize: 12 }}>
|
||||
Upgrade plan →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.reasoning?.summary && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary, #8A8A9A)', lineHeight: 1.6, marginBottom: 8 }}>
|
||||
{result.reasoning.summary}
|
||||
</p>
|
||||
)}
|
||||
{Array.isArray(result.kill_conditions_triggered) && result.kill_conditions_triggered.length > 0 && (
|
||||
<div>
|
||||
<h4
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--grade-d, #FF6B6B)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Kill conditions ({result.kill_conditions_triggered.length})
|
||||
</h4>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 4 }}>
|
||||
{result.kill_conditions_triggered.map((k, i) => (
|
||||
<li
|
||||
key={`${k.code}-${i}`}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid rgba(255,107,107,0.25)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mono"
|
||||
style={{ color: 'var(--grade-d, #FF6B6B)', fontWeight: 700, marginRight: 6 }}
|
||||
>
|
||||
{k.code}
|
||||
</span>
|
||||
{k.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable cache key for the parent's gradedProps map. Exported so the
|
||||
// Slate and tests build the same string.
|
||||
export function propRowKey(prop: PropRowProp): string {
|
||||
return `${prop.player}|${prop.stat_type}|${prop.line}|${prop.direction}|${prop.book || ''}`;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import GameCard, { SlateSport } from '@/components/GameCard';
|
||||
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* The Slate (Session 13).
|
||||
*
|
||||
* Browse-first dashboard surface. Fetches today's odds across the
|
||||
* selected sport(s), groups by game, hands off to GameCard. Owns the
|
||||
* graded-prop Map and the in-flight grading key so PropRow loading
|
||||
* states are accurate.
|
||||
*
|
||||
* Backend contract:
|
||||
* /api/odds/nba — NBA props (existing proxy)
|
||||
* /api/odds/soccer/:league — soccer per league (existing proxy)
|
||||
* /api/odds/mlb — MLB props (may not exist yet —
|
||||
* we surface a friendly "coming soon"
|
||||
* if the endpoint 404s)
|
||||
* /api/scan — submits a grade request (existing)
|
||||
*
|
||||
* State minimalism: one Map for graded props, one nullable loading
|
||||
* key, one error-by-key map. The Slate component is the only writer.
|
||||
*/
|
||||
|
||||
type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';
|
||||
|
||||
const TABS: Array<{ id: SlateTab; label: string }> = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'nba', label: 'NBA' },
|
||||
{ id: 'wnba', label: 'WNBA' },
|
||||
{ id: 'mlb', label: 'MLB' },
|
||||
{ id: 'soccer', label: 'Soccer' },
|
||||
];
|
||||
|
||||
// Per-tab → list of fetch URLs. `null` indicates "no endpoint yet";
|
||||
// the Slate renders a soft "coming soon" badge for that sport rather
|
||||
// than 404-spamming the backend.
|
||||
const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
|
||||
nba: ['/api/odds/nba'],
|
||||
wnba: null, // No /api/odds/wnba proxy yet.
|
||||
mlb: null, // No /api/odds/mlb proxy yet.
|
||||
soccer: ['/api/odds/soccer/wc'],
|
||||
};
|
||||
|
||||
interface RawProp {
|
||||
player?: string;
|
||||
stat_type?: string;
|
||||
line?: number;
|
||||
direction?: 'over' | 'under';
|
||||
book?: string;
|
||||
game_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
}
|
||||
|
||||
interface OddsResponse {
|
||||
sport?: string;
|
||||
props?: RawProp[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SlateGame {
|
||||
sport: SlateSport;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
gameTime?: string;
|
||||
venue?: string;
|
||||
context?: string;
|
||||
props: PropRowProp[];
|
||||
}
|
||||
|
||||
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
const games = new Map<string, SlateGame>();
|
||||
for (const r of rawProps) {
|
||||
if (!r.player || !r.stat_type || r.line == null) continue;
|
||||
const home = r.home_team || '?';
|
||||
const away = r.away_team || '?';
|
||||
const time = r.game_time || '';
|
||||
const key = `${away}__${home}__${time}`;
|
||||
if (!games.has(key)) {
|
||||
games.set(key, {
|
||||
sport,
|
||||
homeTeam: home,
|
||||
awayTeam: away,
|
||||
gameTime: time || undefined,
|
||||
props: [],
|
||||
});
|
||||
}
|
||||
games.get(key)!.props.push({
|
||||
player: r.player,
|
||||
stat_type: r.stat_type,
|
||||
line: Number(r.line),
|
||||
direction: (r.direction as PropRowProp['direction']) || 'over',
|
||||
book: r.book,
|
||||
});
|
||||
}
|
||||
// Sort each game's props by player + stat for stable rendering.
|
||||
for (const g of games.values()) {
|
||||
g.props.sort((a, b) => {
|
||||
if (a.player !== b.player) return a.player.localeCompare(b.player);
|
||||
return a.stat_type.localeCompare(b.stat_type);
|
||||
});
|
||||
}
|
||||
return Array.from(games.values()).sort((a, b) => {
|
||||
const ta = a.gameTime ? Date.parse(a.gameTime) : 0;
|
||||
const tb = b.gameTime ? Date.parse(b.gameTime) : 0;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
export interface SlateProps {
|
||||
initialTab?: SlateTab;
|
||||
tier?: Tier;
|
||||
}
|
||||
|
||||
export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
const [tab, setTab] = useState<SlateTab>(initialTab);
|
||||
const [games, setGames] = useState<SlateGame[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [unsupportedSports, setUnsupportedSports] = useState<SlateSport[]>([]);
|
||||
|
||||
// Grade state — Map keyed by propRowKey.
|
||||
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
|
||||
const [gradingKey, setGradingKey] = useState<string | null>(null);
|
||||
const [errorByKey, setErrorByKey] = useState<Record<string, string | undefined>>({});
|
||||
|
||||
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Fetch + group. Promise.allSettled so one sport failing doesn't blank the slate.
|
||||
const fetchSlate = useCallback(async (active: SlateTab) => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
|
||||
const sportsToFetch: Array<{ sport: SlateSport; urls: string[] }> = [];
|
||||
const unsupported: SlateSport[] = [];
|
||||
const consider = (s: Exclude<SlateTab, 'all'>) => {
|
||||
const urls = FETCH_URLS[s];
|
||||
if (urls === null) unsupported.push(s as SlateSport);
|
||||
else sportsToFetch.push({ sport: s as SlateSport, urls });
|
||||
};
|
||||
|
||||
if (active === 'all') {
|
||||
consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
|
||||
} else {
|
||||
consider(active);
|
||||
}
|
||||
|
||||
if (sportsToFetch.length === 0) {
|
||||
setGames([]);
|
||||
setUnsupportedSports(unsupported);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sportsToFetch.flatMap(({ sport, urls }) =>
|
||||
urls.map((url) =>
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(async (r) => {
|
||||
const body = (await r.json().catch(() => ({}))) as OddsResponse;
|
||||
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
||||
return { sport, body };
|
||||
})
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const allGames: SlateGame[] = [];
|
||||
let firstError: string | null = null;
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
|
||||
allGames.push(...grouped);
|
||||
} else if (!firstError) {
|
||||
firstError = r.reason instanceof Error ? r.reason.message : 'Odds fetch failed';
|
||||
}
|
||||
}
|
||||
|
||||
setGames(allGames);
|
||||
setUnsupportedSports(unsupported);
|
||||
if (allGames.length === 0 && firstError) setFetchError(firstError);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
|
||||
|
||||
// Grading call site. Single source of truth so we never have two
|
||||
// PropRows in-flight from the same prop (the loadingKey enforces it).
|
||||
const onGrade = useCallback(async (prop: PropRowProp) => {
|
||||
const key = propRowKey(prop);
|
||||
if (gradingKey) return; // already a grade in flight — defer
|
||||
setGradingKey(key);
|
||||
setErrorByKey((prev) => ({ ...prev, [key]: undefined }));
|
||||
try {
|
||||
const res = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sport: 'NBA', // overwritten below per game card sport
|
||||
player: prop.player,
|
||||
stat: prop.stat_type,
|
||||
line: prop.line,
|
||||
direction: prop.direction,
|
||||
book: prop.book || 'draftkings',
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
|
||||
if (!res.ok) {
|
||||
setErrorByKey((prev) => ({ ...prev, [key]: body.error || `HTTP ${res.status}` }));
|
||||
return;
|
||||
}
|
||||
const result: PropRowResult = {
|
||||
grade: String(body.grade || 'C'),
|
||||
confidence: typeof body.confidence === 'number' ? body.confidence : undefined,
|
||||
edge_pct: typeof body.edge_pct === 'number' ? body.edge_pct : undefined,
|
||||
reasoning: (body.reasoning as PropRowResult['reasoning']) || undefined,
|
||||
kill_conditions_triggered: (body.kill_conditions_triggered as PropRowResult['kill_conditions_triggered']) || [],
|
||||
tier_gated: !!body.tier_gated,
|
||||
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
|
||||
};
|
||||
setGradedProps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, result);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
setErrorByKey((prev) => ({ ...prev, [key]: 'Network error. Try again.' }));
|
||||
} finally {
|
||||
setGradingKey(null);
|
||||
}
|
||||
}, [gradingKey, session]);
|
||||
|
||||
const onUpgrade = useCallback(() => router.push('/pricing'), [router]);
|
||||
|
||||
// Filter pipeline — searchQuery applied to games + props.
|
||||
const filteredGames = useMemo(() => {
|
||||
if (!searchQuery.trim()) return games;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return games
|
||||
.map((g) => {
|
||||
const homeMatch = g.homeTeam.toLowerCase().includes(q);
|
||||
const awayMatch = g.awayTeam.toLowerCase().includes(q);
|
||||
if (homeMatch || awayMatch) return g;
|
||||
const matchedProps = g.props.filter(
|
||||
(p) => p.player.toLowerCase().includes(q) || p.stat_type.toLowerCase().includes(q),
|
||||
);
|
||||
if (matchedProps.length === 0) return null;
|
||||
return { ...g, props: matchedProps };
|
||||
})
|
||||
.filter((g): g is SlateGame => g !== null);
|
||||
}, [games, searchQuery]);
|
||||
|
||||
// Manual scan fallback URL — pre-fills /scan with the search query
|
||||
// so the user lands on a partially-filled form instead of empty.
|
||||
const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
|
||||
{/* Sticky header — search + tabs */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 64, // matches Nav height
|
||||
zIndex: 5,
|
||||
background: 'var(--bg-0, #0A0A0F)',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search teams, players, stat types…"
|
||||
aria-label="Filter the slate"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--bg-2, #12121A)',
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sport"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 2,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const active = t.id === tab;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setTab(t.id)}
|
||||
className="mono"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '6px 14px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
border: active ? '1px solid var(--grade-a, #00D4A0)' : '1px solid var(--border, #1A1A24)',
|
||||
background: active ? 'var(--grade-a, #00D4A0)' : 'transparent',
|
||||
color: active ? 'var(--bg-0, #0A0A0F)' : 'var(--text-secondary, #8A8A9A)',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{loading && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary, #6B6B7B)' }}>
|
||||
Loading the slate…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError && !loading && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: 14,
|
||||
border: '1px solid var(--grade-d, #FF6B6B)',
|
||||
color: 'var(--grade-d, #FF6B6B)',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{fetchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && filteredGames.length === 0 && (
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 28,
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<p>No games published yet today. Check back closer to first pitch / tip-off / kickoff.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{filteredGames.map((g, i) => (
|
||||
<GameCard
|
||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||
sport={g.sport}
|
||||
homeTeam={g.homeTeam}
|
||||
awayTeam={g.awayTeam}
|
||||
gameTime={g.gameTime}
|
||||
venue={g.venue}
|
||||
context={g.context}
|
||||
props={g.props}
|
||||
gradedProps={gradedProps}
|
||||
loadingKey={gradingKey}
|
||||
errorByKey={errorByKey}
|
||||
tier={tier}
|
||||
onGrade={(p) => onGrade({ ...p })}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{unsupportedSports.length > 0 && !loading && (
|
||||
<p
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unsupportedSports.map((s) => s.toUpperCase()).join(', ')} odds endpoint not configured yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user