Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)
This commit is contained in:
+117
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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? |
|
||||||
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
|
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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's read something.
|
Welcome back. Let'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 }}>
|
||||||
|
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</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} />
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || ''}`;
|
||||||
|
}
|
||||||
@@ -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 “{searchQuery}”.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const signInWithGoogle = useCallback(async () => {
|
// Session 13 — generic OAuth dispatcher. Supabase returns an error
|
||||||
if (!supabase) return;
|
// object when the provider isn't configured in the dashboard
|
||||||
await supabase.auth.signInWithOAuth({
|
// (Apple needs a Service ID + private key; Twitter/X needs an
|
||||||
provider: 'google',
|
// 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` },
|
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||||
});
|
});
|
||||||
}, [supabase]);
|
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 () => {
|
||||||
|
await signInWithProvider('google');
|
||||||
|
}, [signInWithProvider]);
|
||||||
|
|
||||||
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: () => {},
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user