Session 14: Africa checkout, Tank01 NBA/MLB wiring, WNBA+MLB odds proxies, OAuth icons, loading skeletons (1330 tests)

This commit is contained in:
Kev
2026-06-11 10:06:49 -04:00
parent 10159209fa
commit f5d79cf70d
22 changed files with 979 additions and 27 deletions
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* MLB odds proxy (Session 14). Thin forwarder to Express
* `/api/odds/mlb`. Same shape as the NBA + WNBA proxies.
*/
export async function GET(req: NextRequest) {
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/odds/mlb${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: 'Odds service is unreachable. Try again in a moment.' },
{ status: 502 },
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* NBA odds proxy (Session 14).
*
* Forwards GET /api/odds/nba to the Express oddsService route of the
* same shape, preserving the query string (stat_type / book filters
* supported by the upstream `filterProps` step).
*
* Express already validates the sport key and consults the in-process
* cache before hitting odds-api.com — the Next side is a thin pass-
* through so the browser bundle never sees the ODDS_API_KEY.
*/
export async function GET(req: NextRequest) {
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/odds/nba${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return NextResponse.json(data, { status: upstream.status });
}
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: 'Odds service is unreachable. Try again in a moment.' },
{ status: 502 },
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* WNBA odds proxy (Session 14). Thin forwarder to Express
* `/api/odds/wnba`. Off-season → upstream returns an empty `props`
* array which the Slate handles via its empty-state UX.
*/
export async function GET(req: NextRequest) {
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/odds/wnba${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: 'Odds service is unreachable. Try again in a moment.' },
{ status: 502 },
);
}
}
+10
View File
@@ -792,3 +792,13 @@ body.tex-grain::before {
/* Buttons stay LTR so chevrons / arrows render predictably. */
unicode-bidi: isolate;
}
/* Session 14 — shimmer keyframe used by Slate skeleton placeholders
and any other loading surface that wants the same Bloomberg-style
subtle motion. The animation runs over a 200%-wide gradient
background; advancing -100% → 100% slides the highlight band
across the element. */
@keyframes vyndr-shimmer {
0% { background-position: -100% 0; }
100% { background-position: 100% 0; }
}
+41 -9
View File
@@ -5,6 +5,38 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { trackLogin } from '@/lib/analytics';
import Wordmark from '@/components/Wordmark';
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
// Session 14 — small helper so each OAuth button has the same icon+label
// rhythm without inlining the flex container three times.
function ProviderButton({ provider, label, disabled, onClick, children }: {
provider: 'google' | 'apple' | 'twitter';
label: string;
disabled: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={`Continue with ${label}`}
className="btn-ghost"
style={{
width: '100%',
padding: 12,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
}}
data-provider={provider}
>
{children}
<span>Continue with {label}</span>
</button>
);
}
function LoginInner() {
const router = useRouter();
@@ -64,15 +96,15 @@ function LoginInner() {
</p>
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Google
</button>
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Apple
</button>
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with X
</button>
<ProviderButton provider="google" label="Google" disabled={busy} onClick={() => handleOAuth('google')}>
<GoogleIcon />
</ProviderButton>
<ProviderButton provider="apple" label="Apple" disabled={busy} onClick={() => handleOAuth('apple')}>
<AppleIcon />
</ProviderButton>
<ProviderButton provider="twitter" label="X" disabled={busy} onClick={() => handleOAuth('twitter')}>
<XIcon />
</ProviderButton>
</div>
<div style={dividerStyle}>
+29 -9
View File
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { trackSignup } from '@/lib/analytics';
import Wordmark from '@/components/Wordmark';
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
function SignupInner() {
const router = useRouter();
@@ -83,16 +84,35 @@ function SignupInner() {
5 free reads every month. Your first read is fully unlocked. No credit card.
</p>
{/* Session 14 — OAuth buttons with provider icons. Same
ProviderButton helper as /login (inlined here to avoid a
new shared module for two callsites). */}
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Google
</button>
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Apple
</button>
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with X
</button>
{(['google', 'apple', 'twitter'] as const).map((provider) => {
const Icon = provider === 'google' ? GoogleIcon : provider === 'apple' ? AppleIcon : XIcon;
const label = provider === 'google' ? 'Google' : provider === 'apple' ? 'Apple' : 'X';
return (
<button
key={provider}
onClick={() => handleOAuth(provider)}
disabled={busy}
aria-label={`Continue with ${label}`}
className="btn-ghost"
style={{
width: '100%',
padding: 12,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
}}
data-provider={provider}
>
<Icon />
<span>Continue with {label}</span>
</button>
);
})}
</div>
<div style={dividerStyle}>
+21
View File
@@ -243,6 +243,27 @@ export default function Nav() {
{l.label}
</a>
))}
{/* Session 14 — mobile-only "Scan manually" link. The Slate
IS the scan surface on /dashboard, but power users on
mobile may want a direct route to the form. Subtle
tertiary styling so it doesn't compete with the
primary nav links. */}
<a
href="/scan"
onClick={() => setMobileOpen(false)}
style={{
padding: '12px 16px',
fontSize: 13,
color: 'var(--text-secondary, #8A8A9A)',
textDecoration: 'none',
borderRadius: 8,
borderTop: '1px solid var(--border)',
marginTop: 4,
paddingTop: 16,
}}
>
Scan manually
</a>
{user ? (
<button
onClick={() => {
+64
View File
@@ -0,0 +1,64 @@
/**
* OAuth provider icons (Session 14) — inline SVGs, no external icon
* library. 18×18 to sit naturally to the left of the button label.
*
* Why inline: each icon is < 1 KB and we only ship three. Pulling in
* a library (react-icons, lucide) for this would be ~50 KB on the
* client bundle — bad trade. The marks are simplified, brand-safe
* versions (Google's multicolor G, the Apple silhouette, the X glyph).
*/
interface Props {
size?: number;
style?: React.CSSProperties;
}
export function GoogleIcon({ size = 18, style }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
style={style}
aria-hidden="true"
focusable="false"
>
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.6 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3 0 5.8 1.1 7.9 3l5.7-5.7C34 6.5 29.3 4.5 24 4.5 13.2 4.5 4.5 13.2 4.5 24S13.2 43.5 24 43.5c11 0 19.5-8 19.5-19.5 0-1.2-.2-2.4-.4-3.5z" />
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 16 19 13 24 13c3 0 5.8 1.1 7.9 3l5.7-5.7C34 6.5 29.3 4.5 24 4.5 16.3 4.5 9.7 9.1 6.3 14.7z" />
<path fill="#4CAF50" d="M24 43.5c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 34.6 26.7 35.5 24 35.5c-5.2 0-9.6-3.3-11.3-8L6.2 32.4C9.5 38.4 16.2 43.5 24 43.5z" />
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.3 4.4-4.1 5.7l6.2 5.2c-.4.4 6.6-4.8 6.6-14.9 0-1.2-.2-2.4-.4-3.5z" />
</svg>
);
}
export function AppleIcon({ size = 18, style }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
style={style}
aria-hidden="true"
focusable="false"
fill="currentColor"
>
<path d="M17.05 12.04c-.03-2.81 2.3-4.16 2.4-4.22-1.31-1.91-3.34-2.17-4.06-2.2-1.73-.18-3.37 1.02-4.25 1.02-.88 0-2.23-.99-3.66-.96-1.88.03-3.62 1.09-4.59 2.78-1.96 3.4-.5 8.42 1.41 11.18.93 1.35 2.04 2.86 3.5 2.81 1.4-.06 1.93-.91 3.62-.91 1.68 0 2.16.91 3.64.88 1.5-.03 2.46-1.37 3.38-2.73 1.07-1.57 1.51-3.09 1.53-3.17-.03-.01-2.93-1.13-2.96-4.48zM14.43 4.3c.77-.94 1.29-2.24 1.15-3.54-1.11.05-2.46.74-3.26 1.67-.72.83-1.35 2.15-1.18 3.43 1.24.1 2.51-.63 3.29-1.56z" />
</svg>
);
}
export function XIcon({ size = 18, style }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
style={style}
aria-hidden="true"
focusable="false"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.66l-5.214-6.817-5.965 6.817H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231zm-1.16 17.52h1.833L7.084 4.126H5.117l11.967 15.644z" />
</svg>
);
}
+44 -5
View File
@@ -26,6 +26,20 @@ import { useAuth } from '@/contexts/AuthContext';
* key, one error-by-key map. The Slate component is the only writer.
*/
// Session 14 — shimmer skeleton style. Width is a percentage string
// so cards remain responsive at small viewports. The keyframe rule
// lives in globals.css.
function skeletonStyle({ widthPct, height }: { widthPct: number; height: number }): React.CSSProperties {
return {
width: `${widthPct}%`,
height,
borderRadius: 4,
background: 'linear-gradient(90deg, #12121A 0%, #1A1A24 50%, #12121A 100%)',
backgroundSize: '200% 100%',
animation: 'vyndr-shimmer 1.5s ease-in-out infinite',
};
}
type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';
const TABS: Array<{ id: SlateTab; label: string }> = [
@@ -38,11 +52,11 @@ const TABS: Array<{ id: SlateTab; label: string }> = [
// 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.
// than 404-spamming the backend. Session 14 brought WNBA + MLB online.
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.
wnba: ['/api/odds/wnba'],
mlb: ['/api/odds/mlb'],
soccer: ['/api/odds/soccer/wc'],
};
@@ -339,8 +353,33 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
{/* Body */}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary, #6B6B7B)' }}>
Loading the slate
// Session 14 — shimmer skeletons replace the bare "Loading…" text.
// Three placeholder cards approximating GameCard dimensions; the
// shimmer animation lives in globals.css (`@keyframes
// vyndr-shimmer`) so multiple loading surfaces stay in sync.
<div style={{ display: 'grid', gap: 16 }} role="status" aria-label="Loading the slate">
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
background: 'var(--bg-2, #12121A)',
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
padding: 16,
display: 'grid',
gap: 10,
}}
aria-hidden="true"
>
<div style={skeletonStyle({ widthPct: 60, height: 18 })} />
<div style={skeletonStyle({ widthPct: 40, height: 10 })} />
<div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
<div style={skeletonStyle({ widthPct: 88, height: 14 })} />
<div style={skeletonStyle({ widthPct: 70, height: 14 })} />
<div style={skeletonStyle({ widthPct: 80, height: 14 })} />
</div>
</div>
))}
</div>
)}