Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)

This commit is contained in:
Kev
2026-06-11 03:48:07 -04:00
parent d957dee17b
commit 10159209fa
18 changed files with 1452 additions and 64 deletions
+202
View File
@@ -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&apos;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>
);
}