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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user