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}>