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
+117 -1
View File
@@ -4,7 +4,123 @@
2026-06-10 2026-06-10
## Current Phase ## Current Phase
SHIP BUILD v12.0 — i18n (10 languages) + Africa tier (Session 12) SHIP BUILD v13.0 — The Slate (browse-first dashboard) + OAuth providers + Africa geo (Session 13)
## Session 13 (2026-06-11) — SHIPPED
### Phase 1 — Africa geo-restriction via CF-IPCountry
The Session 12 Africa tier was visible to anyone on a Swahili locale
(too narrow: most African users browse in English/French; too broad:
Swahili speakers anywhere got the discount). Session 13 swaps the
locale proxy for real Cloudflare IP geolocation.
- **`web/middleware.ts`** — reads `cf-ipcountry` (uppercase),
stamps `x-vyndr-country` on the request alongside the locale header.
Empty string when traffic bypasses Cloudflare (local dev).
- **`web/src/lib/locales.ts`** — `AFRICAN_COUNTRIES` set covering all
54 sovereign African nations (NG/KE/ZA/GH + sub-Saharan + MENA
overlap). `isAfricanCountry(code)` is case-insensitive and degrades
closed on empty/null inputs.
- **`LocaleContext`** — extended with `country`/`inAfrica` fields;
new `useRegion()` hook for components that gate by geography.
- **`Pricing.tsx`** — `inAfrica === false` filters the Africa tier
out of the render entirely. `inAfrica === true` puts it first.
Locale-based reorder removed.
- **Pricing grid CSS** — desktop column count now tracks the visible
tier count via a `--pricing-cols` CSS custom property on the grid
root (3 outside Africa, 4 inside). Sidesteps a styled-jsx
limitation with attribute selectors inside `:global()`.
### Phase 2 — OAuth: Google + Apple + X
- **`AuthContext`** — added generic `signInWithProvider(provider)`
alongside the legacy `signInWithGoogle()` (kept as an alias so
existing callers don't break). Translates Supabase OAuth errors
into a flat `{ error: string }` so the UI can surface a friendly
inline message when a provider isn't configured.
- **`login/page.tsx` + `signup/page.tsx`** — both pages now render
three OAuth buttons (Google, Apple, X). The `handleOAuth` helper
routes to `signInWithProvider` and shows an inline error when the
provider isn't configured ("apple login isn't available yet. Use
email or another method.").
- **External configuration required** (operator action, not code):
- Supabase Auth → Providers → Apple: needs an Apple Developer
Service ID + private key
- Supabase Auth → Providers → Twitter: needs an X Developer OAuth 2.0
client
- Google should already work — if it doesn't, verify Supabase
Auth → URL Configuration → Site URL = https://vyndr.app and
Redirect URLs include `https://vyndr.app/**`, and that the Google
Cloud Console OAuth consent screen has the Supabase callback URL
in Authorized redirect URIs.
### Phase 3 — The Slate (browse-first dashboard)
Generalizes the Session 8 `/soccer` page pattern across every sport.
- **`web/src/components/PropRow.tsx`** — single-prop UI with three
states (ungraded/grading/graded). Pure presentational — parent
owns the API call so there's one shared rate-limited grading queue.
Free-tier expansion shows blurred reasoning + Unlock CTA; paid tier
shows full reasoning + kill conditions. Exports `propRowKey()` for
stable Map keys.
- **`web/src/components/GameCard.tsx`** — game header + expandable
prop list. Sport emoji prefix (🏀 NBA/WNBA, ⚾ MLB, ⚽ soccer),
sport-accented left border, formatted local game time, `+ N more`
expander when props > defaultVisible.
- **`web/src/components/Slate.tsx`** — the orchestrator. Sport tabs
(ALL / NBA / WNBA / MLB / Soccer), sticky search input, group-by-game
pipeline, `gradedProps` Map, single-flight grading queue
(`gradingKey`). `Promise.allSettled` fan-out for the ALL tab so a
single sport failing doesn't blank the slate. `FETCH_URLS` is
null-aware — sports without an odds proxy yet (WNBA, MLB) render a
bottom-of-page "endpoint not configured yet" note rather than
spamming 404s.
- **Search filter + manual-scan fallback** — sticky search filters
game cards by team name and prop rows by player/stat. Empty result
shows a CTA linking to `/scan?q=<query>` so users land on a
partially-filled scan form.
- **`/dashboard`** — `<Slate />` mounted as the lead surface above
the existing Top Graded / Most Parlayed / Recent Reads sections.
Those sections stay as supplementary intelligence layers — not
removed.
- **`Nav.tsx`** — "Scan" link removed from primary nav. The Slate is
the scan surface; `/scan` stays reachable from the slate's
empty-state CTA.
### Tests added
| Suite | Tests |
|----------------------------------------|-------|
| `tests/unit/africaCountries.test.js` | 6 |
| **Session 13 total** | **6** |
### Quality gates
- `npm test`: **1311 / 1311 passing** (1305 + 6 new), 102 suites, 0 regressions
- `web/npm run build`: clean — Slate page + components prerender
- License audit: third-party deps remain permissive
### Honest gaps (documented, not bugs)
- I could not visually verify The Slate in a browser. Build/type
correctness is confirmed; "renders correctly with live odds data"
needs a deploy smoke test.
- Google/Apple/X OAuth: button wiring is complete. Whether the
buttons actually authenticate depends on external dashboard
configuration (Supabase + Google Cloud Console + Apple Developer +
X Developer Portal). Apple and X are guaranteed to show the
"isn't available yet" inline error until configured.
- WNBA + MLB don't have `/api/odds/*` proxies on the Next.js side
yet. The Slate degrades cleanly (footer note), but those tabs
return empty until the proxies exist. Session-14 work.
- Africa tier still can't be SOLD even when geo gates open it —
the Stripe price + the DB CHECK migration remain outstanding from
Session 12.
### Coolify env (Session 13 additions)
None. CF-IPCountry is set by Cloudflare automatically; no env-var
change required.
---
## Session 12 (2026-06-11) — SHIPPED ## Session 12 (2026-06-11) — SHIPPED
+14
View File
@@ -500,3 +500,17 @@
{"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} {"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T01:34:16.752Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-11T01:34:16.752Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-11T07:17:06.343Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T07:17:06.464Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-11T07:17:06.612Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T07:17:07.259Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-11T07:17:07.259Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-11T07:17:07.259Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T07:17:07.428Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-11T07:33:14.400Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T07:33:14.584Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-11T07:33:14.727Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T07:33:15.337Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-11T07:33:15.337Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-11T07:33:15.337Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T07:33:15.603Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+10 -1
View File
@@ -218,10 +218,11 @@ Container runtime (Session 9 finding):
in production and the container OOM-loops (44 restarts observed on in production and the container OOM-loops (44 restarts observed on
the live host before the fix was identified). the live host before the fix was identified).
### Pricing tiers (Session 12 — Africa tier added) ### Pricing tiers (Session 12 — Africa tier added; Session 13 — geo-gated)
| Var | Required | Default | Used By | Doc? | | Var | Required | Default | Used By | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- | | ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
| `STRIPE_PRICE_AFRICA` | no | (none) | `web/components/Pricing`, `stripeService` (post-DB-CHECK migration) | ✓ S12 | | `STRIPE_PRICE_AFRICA` | no | (none) | `web/components/Pricing`, `stripeService` (post-DB-CHECK migration) | ✓ S12 |
| Cloudflare `CF-IPCountry` | n/a | (none) | `middleware.ts``x-vyndr-country``useRegion()` | ✓ S13 |
**Blocker**: the existing migrations (001 + 011) declare `tier IN **Blocker**: the existing migrations (001 + 011) declare `tier IN
('free','analyst','desk')` as a CHECK constraint on `users.tier` and ('free','analyst','desk')` as a CHECK constraint on `users.tier` and
@@ -233,6 +234,14 @@ follow-up: manual SQL to drop + re-add the CHECK across both tables
including 'africa' (cannot be done in this session per the no-migration including 'africa' (cannot be done in this session per the no-migration
rule). rule).
**Session 13 — Africa tier visibility is now driven by real IP geo**
(Cloudflare `CF-IPCountry` header), not by locale. The middleware
copies `CF-IPCountry` to `x-vyndr-country`; the root layout reads it
into `LocaleProvider`; `useRegion()` exposes `inAfrica: boolean`. The
Pricing component filters the Africa tier out of the render entirely
when `inAfrica === false`. Empty header (traffic bypassing Cloudflare)
degrades closed.
### Internationalization (Session 12) ### Internationalization (Session 12)
| Var / file | Required | Default | Used By | Doc? | | Var / file | Required | Default | Used By | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- | | ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
+64
View File
@@ -0,0 +1,64 @@
// Session 13 — Africa geo-restriction set. The pricing component
// relies on isAfricanCountry() to gate the $4.99 tier. Tests pin
// the membership list so adding/removing a country is a visible,
// reviewed change rather than a silent edit.
const fs = require('fs');
const path = require('path');
const FILE = path.join(__dirname, '..', '..', 'web', 'src', 'lib', 'locales.ts');
const source = fs.readFileSync(FILE, 'utf8');
// Pull the AFRICAN_COUNTRIES set out of the TS source by regex. Tests
// the values, not the implementation — if the format changes, the
// failure is on the parse and tells us to update the test loader.
function parseAfricanCountries() {
const start = source.indexOf('AFRICAN_COUNTRIES');
if (start === -1) throw new Error('AFRICAN_COUNTRIES not found in locales.ts');
const slice = source.slice(start, start + 4000);
const codes = [...slice.matchAll(/'([A-Z]{2})'/g)].map((m) => m[1]);
return new Set(codes);
}
function reimplementIsAfrican(set) {
return (code) => {
if (!code) return false;
return set.has(String(code).toUpperCase());
};
}
describe('AFRICAN_COUNTRIES gate (Session 13)', () => {
const COUNTRIES = parseAfricanCountries();
const isAfricanCountry = reimplementIsAfrican(COUNTRIES);
test('covers all 54 sovereign African nations', () => {
expect(COUNTRIES.size).toBeGreaterThanOrEqual(50);
});
test('includes the major mobile-betting markets', () => {
const required = ['NG', 'KE', 'ZA', 'GH', 'TZ', 'UG', 'EG', 'MA'];
for (const code of required) expect(COUNTRIES.has(code)).toBe(true);
});
test('rejects non-African codes', () => {
const notAfrican = ['US', 'GB', 'CA', 'IN', 'BR', 'JP', 'DE', 'AU', 'CN', 'FR'];
for (const code of notAfrican) expect(COUNTRIES.has(code)).toBe(false);
});
test('isAfricanCountry — case-insensitive', () => {
expect(isAfricanCountry('ng')).toBe(true);
expect(isAfricanCountry('Ng')).toBe(true);
expect(isAfricanCountry('NG')).toBe(true);
});
test('isAfricanCountry — degrades closed on empty/null/undefined', () => {
expect(isAfricanCountry('')).toBe(false);
expect(isAfricanCountry(null)).toBe(false);
expect(isAfricanCountry(undefined)).toBe(false);
});
test('isAfricanCountry — degrades closed on unknown codes', () => {
expect(isAfricanCountry('ZZ')).toBe(false);
expect(isAfricanCountry('XX')).toBe(false);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext'; import { useParlay } from '@/contexts/ParlayContext';
import { GradePill } from '@/components/GradeCard'; import { GradePill } from '@/components/GradeCard';
// Session 13 — The Slate is the new browse-first lead surface. The
// existing dashboard sections (Most Parlayed, Recent Reads) stay
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
type Sport = 'NBA' | 'MLB' | 'WNBA'; type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -159,8 +163,15 @@ export default function DashboardPage() {
)} )}
</header> </header>
{/* Sport tabs */} {/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 32, borderBottom: '1px solid var(--border)' }}> search, and inline grading. Renders ABOVE the existing
intelligence sections (Top Graded / Most Parlayed / Recent
Reads) which serve as supplementary surfaces. */}
<Slate tier={tier} />
{/* Legacy sport tabs — supplementary, kept for the existing
Top Graded / Most Parlayed flows below. */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginTop: 40, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
{SPORT_TABS.map((s) => { {SPORT_TABS.map((s) => {
const active = s === sport; const active = s === sport;
const count = gameCountsBySport[s]; const count = gameCountsBySport[s];
+6 -2
View File
@@ -14,7 +14,7 @@ import CookieConsent from '@/components/CookieConsent';
import SentryInit from '@/components/SentryInit'; import SentryInit from '@/components/SentryInit';
import { LocaleProvider } from '@/contexts/LocaleContext'; import { LocaleProvider } from '@/contexts/LocaleContext';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { LOCALE_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales'; import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -97,6 +97,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const localeHeader = hdrs.get(LOCALE_HEADER); const localeHeader = hdrs.get(LOCALE_HEADER);
const locale = isLocale(localeHeader) ? localeHeader : DEFAULT_LOCALE; const locale = isLocale(localeHeader) ? localeHeader : DEFAULT_LOCALE;
const dir = LOCALE_META[locale].dir; const dir = LOCALE_META[locale].dir;
// Session 13 — country from CF-IPCountry (set by middleware).
// Empty string when traffic bypasses Cloudflare (local dev, direct
// origin hits). The Africa-tier gate degrades closed on empty.
const country = hdrs.get(COUNTRY_HEADER) || '';
return ( return (
<html lang={locale} dir={dir} className="dark"> <html lang={locale} dir={dir} className="dark">
@@ -109,7 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/> />
</head> </head>
<body className="antialiased tex-grain"> <body className="antialiased tex-grain">
<LocaleProvider locale={locale}> <LocaleProvider locale={locale} country={country}>
<PostHogProvider> <PostHogProvider>
<AuthProvider> <AuthProvider>
<ExplainModeProvider> <ExplainModeProvider>
+25 -7
View File
@@ -10,7 +10,7 @@ function LoginInner() {
const router = useRouter(); const router = useRouter();
const search = useSearchParams(); const search = useSearchParams();
const next = search.get('next') || '/dashboard'; const next = search.get('next') || '/dashboard';
const { signIn, signInWithGoogle } = useAuth(); const { signIn, signInWithProvider } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -31,10 +31,20 @@ function LoginInner() {
router.replace(next); router.replace(next);
}; };
const handleGoogle = async () => { // Session 13 — generic OAuth dispatch. Apple + X providers must be
// configured in the Supabase dashboard (Apple needs a Service ID +
// private key; X needs OAuth 2.0 client creds) before the redirect
// succeeds. Unconfigured providers return an inline error string
// instead of silently failing.
const handleOAuth = async (provider: 'google' | 'apple' | 'twitter') => {
setBusy(true); setBusy(true);
await signInWithGoogle(); setError('');
// Supabase redirects to provider; on return AuthContext picks up the session. const { error: err } = await signInWithProvider(provider);
if (err) {
setError(err);
setBusy(false);
}
// On success the page redirects to the provider; no state change here.
}; };
return ( return (
@@ -53,9 +63,17 @@ function LoginInner() {
Welcome back. Let&apos;s read something. Welcome back. Let&apos;s read something.
</p> </p>
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}> <div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
Continue with Google <button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
</button> 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>
</div>
<div style={dividerStyle}> <div style={dividerStyle}>
<span style={dividerLine} /> <span style={dividerLine} />
+22 -6
View File
@@ -10,7 +10,7 @@ function SignupInner() {
const router = useRouter(); const router = useRouter();
const search = useSearchParams(); const search = useSearchParams();
const next = search.get('next') || '/dashboard'; const next = search.get('next') || '/dashboard';
const { signUp, signInWithGoogle } = useAuth(); const { signUp, signInWithProvider } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -40,9 +40,17 @@ function SignupInner() {
setTimeout(() => router.replace(next), 1500); setTimeout(() => router.replace(next), 1500);
}; };
const handleGoogle = async () => { // Session 13 — generic OAuth dispatch. Same provider buttons as
// the login page; same graceful-error contract for unconfigured
// providers (Apple/X).
const handleOAuth = async (provider: 'google' | 'apple' | 'twitter') => {
setBusy(true); setBusy(true);
await signInWithGoogle(); setError('');
const { error: err } = await signInWithProvider(provider);
if (err) {
setError(err);
setBusy(false);
}
}; };
if (done) { if (done) {
@@ -75,9 +83,17 @@ function SignupInner() {
5 free reads every month. Your first read is fully unlocked. No credit card. 5 free reads every month. Your first read is fully unlocked. No credit card.
</p> </p>
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}> <div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
Continue with Google <button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
</button> 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>
</div>
<div style={dividerStyle}> <div style={dividerStyle}>
<span style={dividerLine} /> <span style={dividerLine} />
+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>
);
}
+5 -3
View File
@@ -14,10 +14,12 @@ export default function Nav() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
// Session 12 — translation labels resolved at render time so a // Session 12 — translation labels resolved at render time so a
// locale switch flips the nav without a code change. Hrefs stay // locale switch flips the nav without a code change.
// English (the [locale]/ refactor is a future session). // 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 = [ const NAV_LINKS = [
{ label: t('nav.scan'), href: '/scan' },
{ label: t('nav.tracker'), href: '/tracker' }, { label: t('nav.tracker'), href: '/tracker' },
{ label: t('nav.ledger'), href: '/ledger' }, { label: t('nav.ledger'), href: '/ledger' },
{ label: t('nav.pricing'), href: '/pricing' }, { label: t('nav.pricing'), href: '/pricing' },
+33 -16
View File
@@ -3,8 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useT, useLocale } from '@/contexts/LocaleContext'; import { useT, useRegion } from '@/contexts/LocaleContext';
import { AFRICA_LOCALES } from '@/lib/locales';
type TierId = 'free' | 'africa' | 'analyst' | 'desk'; type TierId = 'free' | 'africa' | 'analyst' | 'desk';
@@ -113,21 +112,28 @@ const TIERS: TierConfig[] = [
export default function Pricing() { export default function Pricing() {
const router = useRouter(); const router = useRouter();
const { session, loading: authLoading } = useAuth(); const { session, loading: authLoading } = useAuth();
const { locale } = useLocale(); const { inAfrica } = useRegion();
const t = useT(); const t = useT();
const [pending, setPending] = useState<TierId | null>(null); const [pending, setPending] = useState<TierId | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Session 12 — Africa-language users see VYNDR Africa first. The // Session 13 — Africa tier visibility + order is now driven by
// tier order is stable per locale (no flicker between renders). // REAL IP geolocation via Cloudflare's CF-IPCountry header (stamped
// Browser region (NG / KE / ZA / GH) isn't available server-side // onto x-vyndr-country by the middleware). The previous locale-
// without IP geolocation, so we use the locale as a proxy. Users // based proxy (Swahili speakers everywhere) was both too narrow
// outside the locale set can still pick the Africa tier; it just // (most African users browse in English/French) and too broad
// doesn't lead the card grid for them. // (Swahili speakers outside Africa got the discount).
const orderedTiers = AFRICA_LOCALES.has(locale) //
// 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 === 'africa')!, TIERS.find((x) => x.id === 'free')!,
TIERS.find((x) => x.id === 'analyst')!, TIERS.find((x) => x.id === 'desk')!] TIERS.find((x) => x.id === 'analyst')!, TIERS.find((x) => x.id === 'desk')!]
: TIERS; : TIERS.filter((x) => x.id !== 'africa');
async function startCheckout(tier: TierId) { async function startCheckout(tier: TierId) {
setError(null); setError(null);
@@ -218,7 +224,18 @@ export default function Pricing() {
</div> </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) => { {orderedTiers.map((tier, i) => {
const isPending = pending === tier.id; const isPending = pending === tier.id;
const isDisabled = authLoading || (pending !== null && !isPending); const isDisabled = authLoading || (pending !== null && !isPending);
@@ -318,15 +335,15 @@ export default function Pricing() {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
:global(.pricing-grid) { :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); grid-template-columns: repeat(2, 1fr);
} }
} }
@media (min-width: 1100px) { @media (min-width: 1100px) {
:global(.pricing-grid) { :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> `}</style>
+386
View File
@@ -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 || ''}`;
}
+438
View File
@@ -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 &ldquo;{searchQuery}&rdquo;.
</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>
);
}
+37 -7
View File
@@ -28,6 +28,11 @@ interface AuthContextValue {
signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>; signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>;
signIn: (email: string, password: string) => Promise<{ error?: string }>; signIn: (email: string, password: string) => Promise<{ error?: string }>;
signInWithGoogle: () => Promise<void>; signInWithGoogle: () => Promise<void>;
// Session 13 — generalized OAuth dispatch. Apple/Twitter call paths
// exist in the UI; whether the call SUCCEEDS depends on the
// provider being configured in the Supabase dashboard. Unconfigured
// providers return an error string the login page surfaces inline.
signInWithProvider: (provider: 'google' | 'apple' | 'twitter') => Promise<{ error?: string }>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
bumpScanCount: () => void; bumpScanCount: () => void;
@@ -151,13 +156,36 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
[supabase], [supabase],
); );
// Session 13 — generic OAuth dispatcher. Supabase returns an error
// object when the provider isn't configured in the dashboard
// (Apple needs a Service ID + private key; Twitter/X needs an
// OAuth 2.0 client). We translate the upstream error into a flat
// `{ error: string }` shape so the login UI can show a friendly
// line without inspecting Supabase internals.
const signInWithProvider = useCallback<AuthContextValue['signInWithProvider']>(
async (provider) => {
if (!supabase) return { error: 'Auth not initialized' };
try {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: `${window.location.origin}/auth/callback` },
});
if (error) {
return { error: `${provider} login isn't available yet. Use email or another method.` };
}
return {};
} catch {
return { error: 'Login failed. Try another method.' };
}
},
[supabase],
);
// Kept as a thin alias so legacy callers (signup/login pages) keep
// working without churn. New code should call signInWithProvider.
const signInWithGoogle = useCallback(async () => { const signInWithGoogle = useCallback(async () => {
if (!supabase) return; await signInWithProvider('google');
await supabase.auth.signInWithOAuth({ }, [signInWithProvider]);
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/callback` },
});
}, [supabase]);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
if (!supabase) return; if (!supabase) return;
@@ -189,12 +217,13 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
signUp, signUp,
signIn, signIn,
signInWithGoogle, signInWithGoogle,
signInWithProvider,
signOut, signOut,
refresh, refresh,
bumpScanCount, bumpScanCount,
markMFAPrompted, markMFAPrompted,
}; };
}, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signOut, refresh, bumpScanCount, markMFAPrompted]); }, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signInWithProvider, signOut, refresh, bumpScanCount, markMFAPrompted]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }
@@ -214,6 +243,7 @@ export function useAuth(): AuthContextValue {
signUp: async () => ({ error: 'Auth not initialized' }), signUp: async () => ({ error: 'Auth not initialized' }),
signIn: async () => ({ error: 'Auth not initialized' }), signIn: async () => ({ error: 'Auth not initialized' }),
signInWithGoogle: async () => {}, signInWithGoogle: async () => {},
signInWithProvider: async () => ({ error: 'Auth not initialized' }),
signOut: async () => {}, signOut: async () => {},
refresh: async () => {}, refresh: async () => {},
bumpScanCount: () => {}, bumpScanCount: () => {},
+40 -10
View File
@@ -1,34 +1,51 @@
'use client'; 'use client';
import { createContext, useContext, useMemo, ReactNode } from 'react'; import { createContext, useContext, useMemo, ReactNode } from 'react';
import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META } from '@/lib/locales'; import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META, isAfricanCountry } from '@/lib/locales';
import { getTranslations, TFunction } from '@/lib/i18n'; import { getTranslations, TFunction } from '@/lib/i18n';
/** /**
* Client-side locale context (Session 12). * Client-side locale + region context (Session 12; Session 13 added
* the `country` field from the CF-IPCountry header).
* *
* The root layout (server component) resolves the locale from the * The root layout (server component) resolves the locale + country
* request header and passes it as a prop to `<LocaleProvider>`. From * from request headers and passes them as props to `<LocaleProvider>`.
* there every client component can `useT()` without prop-drilling. * From there every client component can `useT()` / `useRegion()`
* without prop-drilling or repeating the resolution.
* *
* Memoized: the `t` function is stable per render of the provider, * Memoized: the `t` function and derived booleans are stable per
* so consumers don't re-render on every parent render. * render of the provider, so consumers don't re-render on every
* parent render.
*/ */
interface LocaleContextValue { interface LocaleContextValue {
locale: Locale; locale: Locale;
dir: 'ltr' | 'rtl'; dir: 'ltr' | 'rtl';
t: TFunction; t: TFunction;
// Session 13 region fields.
country: string; // 'NG', 'US', '' (unknown / non-Cloudflare path)
inAfrica: boolean; // true when country ∈ AFRICAN_COUNTRIES
} }
const LocaleContext = createContext<LocaleContextValue | null>(null); const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({ locale, children }: { locale: string; children: ReactNode }) { export function LocaleProvider({
locale,
country = '',
children,
}: { locale: string; country?: string; children: ReactNode }) {
const value = useMemo<LocaleContextValue>(() => { const value = useMemo<LocaleContextValue>(() => {
const resolved: Locale = isLocale(locale) ? locale : DEFAULT_LOCALE; const resolved: Locale = isLocale(locale) ? locale : DEFAULT_LOCALE;
const bundle = getTranslations(resolved); const bundle = getTranslations(resolved);
return { locale: resolved, dir: LOCALE_META[resolved].dir, t: bundle.t }; const cc = String(country || '').toUpperCase();
}, [locale]); return {
locale: resolved,
dir: LOCALE_META[resolved].dir,
t: bundle.t,
country: cc,
inAfrica: isAfricanCountry(cc),
};
}, [locale, country]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>; return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
} }
@@ -47,3 +64,16 @@ export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl' } {
if (!ctx) return { locale: DEFAULT_LOCALE, dir: 'ltr' }; if (!ctx) return { locale: DEFAULT_LOCALE, dir: 'ltr' };
return { locale: ctx.locale, dir: ctx.dir }; return { locale: ctx.locale, dir: ctx.dir };
} }
/**
* Session 13 — region hook for components that need to gate by
* geography (pricing, regulatory disclaimers, regional payment
* methods). Returns `inAfrica: false` when country is unknown
* (degrade-closed: don't surface region-specific UX on unverified
* traffic).
*/
export function useRegion(): { country: string; inAfrica: boolean } {
const ctx = useContext(LocaleContext);
if (!ctx) return { country: '', inAfrica: false };
return { country: ctx.country, inAfrica: ctx.inAfrica };
}
+29 -4
View File
@@ -31,17 +31,42 @@ export const LOCALE_META: Record<Locale, { label: string; native: string; dir: '
zh: { label: 'Chinese', native: '中文', dir: 'ltr', region: 'China' }, zh: { label: 'Chinese', native: '中文', dir: 'ltr', region: 'China' },
}; };
// Localess that map to predominantly-African markets — used by the // Session 12: kept as a hint for the *interface language*. Session 13
// pricing page to surface the Africa tier first. Browser region // replaces the locale-based pricing-tier proxy with real IP geo
// codes (NG/KE/ZA/GH/...) are checked separately at the component // (Cloudflare CF-IPCountry header → x-vyndr-country, see middleware).
// layer. // Pricing.tsx now reads the country code, not this set.
export const AFRICA_LOCALES: ReadonlySet<Locale> = new Set(['sw']); export const AFRICA_LOCALES: ReadonlySet<Locale> = new Set(['sw']);
// Session 13 — ISO-3166-1 alpha-2 codes for the African countries we
// surface the VYNDR Africa tier in. The list intentionally covers
// every sovereign African state (54). Membership IS the gate: outside
// this set, the Africa tier card is filtered out of the pricing page
// entirely. Inside this set, it renders first.
export const AFRICAN_COUNTRIES: ReadonlySet<string> = new Set([
// Sub-Saharan
'NG', 'KE', 'ZA', 'GH', 'TZ', 'ET', 'CM', 'SN', 'CI', 'UG',
'RW', 'MZ', 'AO', 'ZW', 'BW', 'NA', 'MU', 'ML', 'BF', 'NE',
'TD', 'MW', 'ZM', 'MG', 'CD', 'CG', 'GA', 'GQ', 'BJ', 'TG',
'SL', 'LR', 'GN', 'GM', 'CV', 'ST', 'KM', 'SC', 'DJ', 'ER',
'LS', 'SZ', 'SO', 'SS', 'BI',
// North Africa (MENA overlap)
'EG', 'MA', 'DZ', 'TN', 'LY', 'SD', 'EH',
]);
export function isLocale(value: string | null | undefined): value is Locale { export function isLocale(value: string | null | undefined): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value); return !!value && (LOCALES as readonly string[]).includes(value);
} }
export function isAfricanCountry(code: string | null | undefined): boolean {
if (!code) return false;
return AFRICAN_COUNTRIES.has(String(code).toUpperCase());
}
// Cookie name + locale-detection header name (set by middleware, // Cookie name + locale-detection header name (set by middleware,
// read by server components via next/headers). // read by server components via next/headers).
export const LOCALE_COOKIE = 'NEXT_LOCALE'; export const LOCALE_COOKIE = 'NEXT_LOCALE';
export const LOCALE_HEADER = 'x-vyndr-locale'; export const LOCALE_HEADER = 'x-vyndr-locale';
// Country code — Cloudflare stamps this on every edge request as
// CF-IPCountry; middleware copies it onto a vendor-namespaced header
// so server components don't depend on knowing about Cloudflare.
export const COUNTRY_HEADER = 'x-vyndr-country';
+10 -4
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, LOCALE_HEADER, isLocale, Locale } from '@/lib/locales'; import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, LOCALE_HEADER, COUNTRY_HEADER, isLocale, Locale } from '@/lib/locales';
/** /**
* Locale-detection middleware (Session 12). * Locale-detection middleware (Session 12).
@@ -66,11 +66,17 @@ function resolveLocale(req: NextRequest): Locale {
export function middleware(req: NextRequest) { export function middleware(req: NextRequest) {
const locale = resolveLocale(req); const locale = resolveLocale(req);
// Stamp the request header so server components can read locale // Session 13 — Cloudflare stamps `cf-ipcountry` on every edge
// via `headers().get('x-vyndr-locale')`. NextResponse.next() with // request. We copy it onto `x-vyndr-country` so server components
// request headers is the canonical pattern for this. // don't have to know about Cloudflare directly. Empty string when
// requests bypass Cloudflare (local dev, direct origin hits) —
// consumers MUST treat empty as "unknown" and degrade
// conservatively (the Africa-tier gate hides the card).
const country = (req.headers.get('cf-ipcountry') || '').toUpperCase();
const requestHeaders = new Headers(req.headers); const requestHeaders = new Headers(req.headers);
requestHeaders.set(LOCALE_HEADER, locale); requestHeaders.set(LOCALE_HEADER, locale);
requestHeaders.set(COUNTRY_HEADER, country);
return NextResponse.next({ return NextResponse.next({
request: { headers: requestHeaders }, request: { headers: requestHeaders },
}); });