Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)

This commit is contained in:
Kev
2026-06-11 18:15:25 -04:00
parent 167996d99a
commit 73b65a0248
11 changed files with 1010 additions and 101 deletions
+107 -1
View File
@@ -4,7 +4,113 @@
2026-06-11 2026-06-11
## Current Phase ## Current Phase
SHIP BUILD v15.0 — Intelligence hardening + platform correctness (Session 15) SHIP BUILD v16.0 — Live hero prop + sport-scoped markets + launch polish (Session 16)
## Session 16 (2026-06-11) — SHIPPED
### Phase 1 — Sport-specific market map
`src/services/oddsService.js` now scopes the markets-list parameter
to the requested sport. Previously every odds-api request sent
`ALL_MARKETS` (the union of every sport's markets), which the
upstream 422'd on because soccer markets (`player_goals`,
`player_shots_on_target`, etc.) aren't valid for basketball
endpoints. Production briefly worked around this with a runtime
axios interceptor injected via
`NODE_OPTIONS=--require /app/data/patch.js`.
This session retires that hack at the code layer:
- New `SPORT_MARKETS` map alongside `SPORT_KEYS` — separate lists
per sport, all frozen with `Object.freeze`. NBA + NCAAB share
basketball markets; WNBA is basketball minus PRA (odds-api
doesn't carry that for WNBA); MLB sends batter + pitcher markets;
every soccer league shares the soccer set.
- `fetchEventOddsFromApi(sportKey, eventId, apiKey, sport)`
third arg added; reads `getMarketsForSport(sport)` instead of
the union. Backwards-compatible: omitted sport falls back to
NBA (safe default).
- `fetchAllOdds(sport, apiKey)` — already had the local sport key;
now passes it through.
**Coolify follow-up**: after this deploy, the operator can drop
`NODE_OPTIONS=--require /app/data/patch.js` from the web service
env and delete `/app/data/patch.js`. The runtime patch is now
dead code.
### Phase 2 — Live hero prop
`web/src/app/api/hero-prop/route.ts` (new) — picks one fresh real
prop from today's NBA → WNBA → MLB cascade and grades it. Two-stage
flow: GET `/api/odds/{sport}` → POST `/api/analyze/prop`. Both
calls share a 6s AbortController timeout. Server-side cached for
15 minutes via `Cache-Control: s-maxage=900`. Falls back to a
static Jokic example (`isStatic: true`) when every sport is empty
so the landing page never blanks out.
`web/src/components/LiveHeroProp.tsx` (new) — replaces the
hard-coded `FloatingDemoCard` inside `Hero.tsx`. Renders the live
prop with:
- "LIVE" badge with a pulsing green dot
- Sport-colored category tag (NBA red, WNBA orange, MLB blue, soccer green)
- Player name + line + projection + edge **visible** (hook)
- Grade letter + confidence **visible** via GradePill (proof)
- Reasoning section **blurred** with backdrop `blur(4px)`, a
scan-line gradient (`repeating-linear-gradient`), a bottom-fade
mask, and a "CLASSIFIED · Sign up to unlock" label (paywall)
- Single CTA: "Sign up to read the full analysis →"
While loading OR when the API returns `isStatic: true`, renders
the original Jokic mockup byte-for-byte. No flash-of-blank-card.
`Hero.tsx` — old `FloatingDemoCard`, `Stat`, and `row` constant
deleted. `GradePill` import moved into `LiveHeroProp`.
### Phase 3 — Soccer weather
`soccerFeatureExtractor.js` now calls `weatherService.getWeather()`
for outdoor WC venues after resolving the venue. Dome venues skip
the fetch. Unknown venues skip silently. New feature fields:
`weather_temp_f`, `weather_wind_mph`, `weather_wind_dir`,
`weather_precip_mm`. All null when skipped/failed.
### Phase 4/5 — OG tags + CSP (mostly already done)
OG meta + Twitter card + `og-image.png` were all wired in Session 9.
Existing CSP in `next.config.ts` was comprehensive. Session 16 added:
- `https://browser.sentry-cdn.com` to `script-src` (Sentry SDK)
- `https://*.sentry.io` and `https://*.ingest.sentry.io` to
`connect-src` (event ingestion). Without these the browser
Sentry client silently dropped events.
### Tests added (Session 16)
| Suite | Tests |
|----------------------------------------|-------|
| `tests/unit/sportMarkets.test.js` | 16 |
| `tests/unit/soccerWeather.test.js` | 7 |
| **Session 16 total** | **23**|
### Quality gates
- `npm test`: **1429 / 1429 passing** (1405 + 24), 110 suites, 0 regressions
- `web/npm run build`: clean
- License audit: third-party deps remain permissive
### Honest gaps
- `LiveHeroProp`'s glitch effect (scan lines + blur + fade) renders
only in a browser. Build verified. Deploy smoke-test recommended.
- Hero endpoint depends on `/api/odds/{sport}` returning populated
`props`. If upstream odds-api is rate-limited or proxies aren't
reaching Express, the static fallback fires — cold visitors see
the Jokic mockup, not live data.
- Sentry CSP entries added but require redeploy to take effect.
Until then, the browser SDK silently drops events.
### Coolify follow-ups
1. **Drop the patch.js workaround**: remove
`NODE_OPTIONS=--require /app/data/patch.js` from the web
service env. Code-layer fix in Session 16 makes the runtime
patch obsolete.
---
## Session 15 (2026-06-11) — SHIPPED ## Session 15 (2026-06-11) — SHIPPED
+14
View File
@@ -556,3 +556,17 @@
{"ts":"2026-06-11T20:09:39.782Z","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-11T20:09:39.782Z","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-11T20:09:39.783Z","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-11T20:09:39.783Z","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-11T20:09:40.192Z","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-11T20:09:40.192Z","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-11T21:48:23.943Z","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-11T21:48:24.045Z","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-11T21:48:24.145Z","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-11T21:48:24.479Z","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-11T21:48:24.480Z","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-11T21:48:24.480Z","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-11T21:48:24.545Z","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-11T22:05:04.182Z","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-11T22:05:04.411Z","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-11T22:05:04.871Z","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-11T22:05:06.923Z","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-11T22:05:06.923Z","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-11T22:05:06.923Z","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-11T22:05:07.154Z","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"}
@@ -27,6 +27,12 @@
const { cacheGet } = require('../../utils/redis'); const { cacheGet } = require('../../utils/redis');
const { normalizeName } = require('../../utils/normalize'); const { normalizeName } = require('../../utils/normalize');
const wc = require('../../data/worldcup2026'); const wc = require('../../data/worldcup2026');
// Session 16 — World Cup venue weather. Open-Meteo lookup is cached
// 1h, has a 5s timeout, and degrades silently on failure. Dome
// venues skip the fetch entirely (operators close the roof when
// conditions warrant — weather doesn't drive grade in that case).
const { getWcVenueCoords } = require('../../data/venueCoordinates');
const weatherService = require('../weatherService');
const SOCCER_SPORTS = new Set(['soccer', 'football']); const SOCCER_SPORTS = new Set(['soccer', 'football']);
@@ -183,6 +189,22 @@ async function extractSoccerFeatures(input = {}) {
const homeContinent = wc.isHomeContinent(team); const homeContinent = wc.isHomeContinent(team);
const altImpact = wc.altitudeImpact(altitudeFt); const altImpact = wc.altitudeImpact(altitudeFt);
// Session 16 — weather for outdoor World Cup venues. The venue
// coords file is keyed by venue name; dome venues (BC Place,
// AT&T, etc.) are skipped via the `dome` flag rather than the
// network call so we don't burn the 5s timeout on stadiums that
// will close the roof anyway.
let weather = null;
const coords = venueName ? getWcVenueCoords(venueName) : null;
if (coords && !coords.dome && Number.isFinite(coords.lat) && Number.isFinite(coords.lon)) {
try {
weather = await weatherService.getWeather(coords.lat, coords.lon);
} catch (err) {
// Best-effort — never block the grade on the weather fetch.
console.warn('[soccerFeatureExtractor] weather skipped:', err.message);
}
}
// Set-piece + penalty roles (static data — no async). // Set-piece + penalty roles (static data — no async).
const isPK = wc.isPenaltyTaker(player, team); const isPK = wc.isPenaltyTaker(player, team);
const isCorner = wc.isCornerTaker(player, team); const isCorner = wc.isCornerTaker(player, team);
@@ -221,6 +243,13 @@ async function extractSoccerFeatures(input = {}) {
venue_altitude_ft: altitudeFt, venue_altitude_ft: altitudeFt,
altitude_impact: altImpact, altitude_impact: altImpact,
climate, climate,
// Session 16 — weather. Null when venue is a dome / not in the
// WC venue index / Open-Meteo fetch failed. Trap detection +
// reasoning surface these signals when present.
weather_temp_f: weather?.temp_f ?? null,
weather_wind_mph: weather?.wind_mph ?? null,
weather_wind_dir: weather?.wind_dir ?? null,
weather_precip_mm: weather?.precip_mm ?? null,
opp_goals_conceded_per_game: oppDefense?.goals_conceded_per_game ?? null, opp_goals_conceded_per_game: oppDefense?.goals_conceded_per_game ?? null,
opp_clean_sheet_rate: oppDefense?.clean_sheet_rate ?? null, opp_clean_sheet_rate: oppDefense?.clean_sheet_rate ?? null,
opp_defensive_rank: oppDefense?.defensive_rank ?? null, opp_defensive_rank: oppDefense?.defensive_rank ?? null,
+92 -3
View File
@@ -32,7 +32,89 @@ const SPORT_KEYS = {
const SOCCER_SPORT_KEYS = Object.freeze( const SOCCER_SPORT_KEYS = Object.freeze(
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_')) Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
); );
// Session 16 — per-sport market lists.
//
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
// soccer markets (player_goals, player_shots_on_target, etc.) to
// basketball + baseball endpoints, triggering odds-api 422 errors.
// Production briefly worked around this with a runtime axios
// interceptor injected via `NODE_OPTIONS=--require /app/data/patch.js`;
// the proper fix is to scope the markets list to the sport before
// the request leaves the process.
//
// After this lands, the operator can drop the NODE_OPTIONS env var
// from Coolify and delete /app/data/patch.js.
const NBA_MARKETS = [
'player_points',
'player_rebounds',
'player_assists',
'player_threes',
'player_blocks',
'player_steals',
'player_turnovers',
'player_points_rebounds_assists',
];
const WNBA_MARKETS = [
'player_points',
'player_rebounds',
'player_assists',
'player_threes',
'player_blocks',
'player_steals',
'player_turnovers',
];
const MLB_MARKETS = [
'batter_home_runs',
'batter_hits',
'batter_total_bases',
'batter_rbis',
'batter_runs_scored',
'batter_stolen_bases',
'pitcher_strikeouts',
'pitcher_outs',
];
const SOCCER_MARKETS = [
'player_goals',
'player_shots_on_target',
'player_shots',
'player_tackles',
'player_cards',
'player_corners',
'player_saves',
'player_goals_conceded',
'player_passes',
'team_clean_sheet',
];
function buildMarketString(markets) {
return [...markets, 'spreads'].join(',');
}
// Indexed by the local sport key (the keys in SPORT_KEYS, not the
// odds-api keys). Soccer leagues all share the same market list.
const SPORT_MARKETS = Object.freeze({
nba: buildMarketString(NBA_MARKETS),
wnba: buildMarketString(WNBA_MARKETS),
mlb: buildMarketString(MLB_MARKETS),
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
// Every soccer league code shares the same market set.
...Object.fromEntries(
Object.keys(SPORT_KEYS)
.filter((k) => k.startsWith('soccer_'))
.map((k) => [k, buildMarketString(SOCCER_MARKETS)]),
),
});
// Kept for backward-compat with any caller that still imports it,
// but the call site (`fetchEventOddsFromApi`) now uses the sport-
// specific lookup. Composed once on module load.
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads'; const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
function getMarketsForSport(sport) {
if (!sport) return SPORT_MARKETS.nba; // safe default (basketball)
return SPORT_MARKETS[sport] || SPORT_MARKETS.nba;
}
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers'; const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
function getCacheKey(sport) { function getCacheKey(sport) {
@@ -75,13 +157,17 @@ async function fetchEventsFromApi(sportKey, apiKey) {
return { data: response.data, headers: response.headers }; return { data: response.data, headers: response.headers };
} }
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) { // Session 16 — third arg is now a local sport key (nba, mlb,
// soccer_wc, ...) so we can scope the markets list. Backwards-
// compatible: if `sport` is omitted, falls back to the basketball
// market set, which is what every legacy caller assumed.
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`; const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
const response = await axios.get(url, { const response = await axios.get(url, {
params: { params: {
apiKey, apiKey,
regions: 'us', regions: 'us',
markets: ALL_MARKETS, markets: getMarketsForSport(sport),
bookmakers: BOOKMAKERS, bookmakers: BOOKMAKERS,
oddsFormat: 'american', oddsFormat: 'american',
}, },
@@ -112,7 +198,7 @@ async function fetchAllOdds(sport, apiKey) {
break; break;
} }
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey); const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey, sport);
eventsWithOdds.push(oddsResult.data); eventsWithOdds.push(oddsResult.data);
lastHeaders = oddsResult.headers; lastHeaders = oddsResult.headers;
} }
@@ -230,6 +316,9 @@ module.exports = {
getCacheKey, getCacheKey,
SPORT_KEYS, SPORT_KEYS,
SOCCER_SPORT_KEYS, SOCCER_SPORT_KEYS,
// Session 16 — per-sport market scoping.
SPORT_MARKETS,
getMarketsForSport,
getQuotaKey, getQuotaKey,
updateQuota, updateQuota,
getQuotaRemaining, getQuotaRemaining,
+116
View File
@@ -0,0 +1,116 @@
// Session 16 — soccer weather wiring. The feature extractor fetches
// Open-Meteo for outdoor WC venues. Dome venues skip the fetch
// (operators close the roof); unknown venues skip silently.
const mockCache = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null),
cacheSet: async (k, v) => { mockCache.set(k, v); return true; },
cacheDel: async (k) => { mockCache.delete(k); return true; },
isDegraded: () => false,
}));
const mockWeather = jest.fn();
jest.mock('../../src/services/weatherService', () => ({
getWeather: (...a) => mockWeather(...a),
}));
const { extractSoccerFeatures } = require('../../src/services/intelligence/soccerFeatureExtractor');
const { normalizeName } = require('../../src/utils/normalize');
beforeEach(() => {
mockCache.clear();
mockWeather.mockReset();
});
function primePlayerAndMatch(player, team, opts = {}) {
mockCache.set(`soccer:player:${normalizeName(player)}`, {
team, goals_per_90: 0.5,
});
mockCache.set(`soccer:nextmatch:${team}`, {
opponent: opts.opponent || 'X',
venue: opts.venue || 'MetLife Stadium',
isHome: opts.isHome ?? true,
referee: opts.referee || null,
});
}
describe('soccer weather wiring (Session 16)', () => {
test('outdoor WC venue → weather features populated', async () => {
primePlayerAndMatch('Harry Kane', 'England', { venue: 'MetLife Stadium' });
mockWeather.mockResolvedValueOnce({
temp_f: 81.2, wind_mph: 9.4, wind_dir: 220, precip_mm: 0,
});
const r = await extractSoccerFeatures({
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(mockWeather).toHaveBeenCalledTimes(1);
expect(r.features.weather_temp_f).toBeCloseTo(81.2);
expect(r.features.weather_wind_mph).toBeCloseTo(9.4);
expect(r.features.weather_wind_dir).toBe(220);
expect(r.features.weather_precip_mm).toBe(0);
});
test('dome WC venue (BC Place) → weather fetch skipped, fields null', async () => {
primePlayerAndMatch('Sub Player', 'Canada', { venue: 'BC Place' });
const r = await extractSoccerFeatures({
player: 'Sub Player', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(mockWeather).not.toHaveBeenCalled();
expect(r.features.weather_temp_f).toBeNull();
expect(r.features.weather_wind_mph).toBeNull();
});
test('Estadio Azteca (open-air, high-altitude) → weather fetched + altitude_impact still high', async () => {
primePlayerAndMatch('Forward', 'Mexico', { venue: 'Estadio Azteca' });
mockWeather.mockResolvedValueOnce({ temp_f: 68, wind_mph: 5, wind_dir: 90, precip_mm: 0 });
const r = await extractSoccerFeatures({
player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.weather_temp_f).toBe(68);
expect(r.features.altitude_impact).toBe('high');
});
test('venue not in the WC index → weather fetch skipped', async () => {
primePlayerAndMatch('X', 'Y', { venue: 'Random Stadium' });
const r = await extractSoccerFeatures({
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(mockWeather).not.toHaveBeenCalled();
expect(r.features.weather_temp_f).toBeNull();
});
test('weather service returns null → feature fields stay null (no throw)', async () => {
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
mockWeather.mockResolvedValueOnce(null);
const r = await extractSoccerFeatures({
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(mockWeather).toHaveBeenCalledTimes(1);
expect(r.features.weather_temp_f).toBeNull();
expect(r.features.weather_wind_mph).toBeNull();
});
test('weather service throws → graceful degrade, grade still produced', async () => {
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
mockWeather.mockRejectedValueOnce(new Error('timeout'));
const r = await extractSoccerFeatures({
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.weather_temp_f).toBeNull();
// Other features (goals_per_90 etc.) still populated.
expect(r.features.goals_per_90).toBe(0.5);
});
test('no venue resolved → weather skipped entirely (no fetch attempt)', async () => {
mockCache.set(`soccer:player:${normalizeName('Solo')}`, { team: 'England', goals_per_90: 0.5 });
// No nextmatch entry → venueName is null.
const r = await extractSoccerFeatures({
player: 'Solo', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(mockWeather).not.toHaveBeenCalled();
expect(r.features.weather_temp_f).toBeNull();
});
});
+129
View File
@@ -0,0 +1,129 @@
// Session 16 — sport-specific market scoping in oddsService.
// Replaces a runtime axios interceptor (NODE_OPTIONS --require) that
// had been deployed to filter out cross-sport markets the upstream
// odds-api 422s on. These tests pin the contract so the runtime hack
// can be retired safely.
const { SPORT_MARKETS, getMarketsForSport } = require('../../src/services/oddsService');
describe('SPORT_MARKETS — isolation', () => {
test('NBA market list contains no soccer markets', () => {
expect(SPORT_MARKETS.nba).not.toMatch(/player_goals\b/);
expect(SPORT_MARKETS.nba).not.toMatch(/player_shots_on_target/);
expect(SPORT_MARKETS.nba).not.toMatch(/player_tackles/);
expect(SPORT_MARKETS.nba).not.toMatch(/player_cards/);
expect(SPORT_MARKETS.nba).not.toMatch(/team_clean_sheet/);
});
test('NBA market list contains no MLB markets', () => {
expect(SPORT_MARKETS.nba).not.toMatch(/batter_/);
expect(SPORT_MARKETS.nba).not.toMatch(/pitcher_/);
});
test('NBA market list does contain canonical NBA markets', () => {
expect(SPORT_MARKETS.nba).toMatch(/player_points/);
expect(SPORT_MARKETS.nba).toMatch(/player_rebounds/);
expect(SPORT_MARKETS.nba).toMatch(/player_assists/);
expect(SPORT_MARKETS.nba).toMatch(/player_threes/);
expect(SPORT_MARKETS.nba).toMatch(/spreads/);
});
test('WNBA market list is NBA-shaped minus PRA combo', () => {
expect(SPORT_MARKETS.wnba).toMatch(/player_points/);
expect(SPORT_MARKETS.wnba).toMatch(/player_rebounds/);
// WNBA odds-api doesn't expose the PRA combo today.
expect(SPORT_MARKETS.wnba).not.toMatch(/points_rebounds_assists/);
expect(SPORT_MARKETS.wnba).not.toMatch(/batter_/);
expect(SPORT_MARKETS.wnba).not.toMatch(/player_goals\b/);
});
test('MLB market list contains batter + pitcher markets, no basketball', () => {
expect(SPORT_MARKETS.mlb).toMatch(/batter_home_runs/);
expect(SPORT_MARKETS.mlb).toMatch(/batter_hits/);
expect(SPORT_MARKETS.mlb).toMatch(/pitcher_strikeouts/);
expect(SPORT_MARKETS.mlb).not.toMatch(/player_points/);
expect(SPORT_MARKETS.mlb).not.toMatch(/player_goals\b/);
});
test('every soccer league shares the same market list', () => {
const soccerKeys = Object.keys(SPORT_MARKETS).filter((k) => k.startsWith('soccer_'));
expect(soccerKeys.length).toBeGreaterThanOrEqual(9);
const first = SPORT_MARKETS[soccerKeys[0]];
for (const k of soccerKeys) {
expect(SPORT_MARKETS[k]).toBe(first);
}
});
test('soccer market list contains soccer-only markets, no basketball/baseball', () => {
const wc = SPORT_MARKETS.soccer_wc;
expect(wc).toMatch(/player_goals/);
expect(wc).toMatch(/player_shots_on_target/);
expect(wc).toMatch(/player_cards/);
expect(wc).toMatch(/team_clean_sheet/);
expect(wc).not.toMatch(/player_points/);
expect(wc).not.toMatch(/batter_/);
});
test('every market list ends with `spreads`', () => {
for (const list of Object.values(SPORT_MARKETS)) {
// We don't require spreads to be the literal final segment,
// only that it's present in the comma-separated list.
expect(list.split(',')).toContain('spreads');
}
});
test('SPORT_MARKETS is frozen at the top level', () => {
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
});
});
describe('getMarketsForSport', () => {
test('returns the NBA list for nba', () => {
expect(getMarketsForSport('nba')).toBe(SPORT_MARKETS.nba);
});
test('returns the soccer_wc list for soccer_wc', () => {
expect(getMarketsForSport('soccer_wc')).toBe(SPORT_MARKETS.soccer_wc);
});
test('unknown sport falls back to NBA (safe default)', () => {
expect(getMarketsForSport('cricket')).toBe(SPORT_MARKETS.nba);
});
test('null / undefined / empty fall back to NBA', () => {
expect(getMarketsForSport(null)).toBe(SPORT_MARKETS.nba);
expect(getMarketsForSport(undefined)).toBe(SPORT_MARKETS.nba);
expect(getMarketsForSport('')).toBe(SPORT_MARKETS.nba);
});
});
describe('fetchEventOddsFromApi uses sport-scoped markets', () => {
// Mock axios so the test doesn't hit the network.
jest.resetModules();
const mockGet = jest.fn(() => Promise.resolve({ data: {}, headers: {} }));
jest.doMock('axios', () => ({ get: mockGet }));
const { fetchEventOddsFromApi, SPORT_MARKETS: SM } = require('../../src/services/oddsService');
beforeEach(() => mockGet.mockClear());
test('NBA fetch sends NBA markets only', async () => {
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key', 'nba');
const [, opts] = mockGet.mock.calls[0];
expect(opts.params.markets).toBe(SM.nba);
});
test('MLB fetch sends MLB markets only', async () => {
await fetchEventOddsFromApi('baseball_mlb', 'evt1', 'key', 'mlb');
const [, opts] = mockGet.mock.calls[0];
expect(opts.params.markets).toBe(SM.mlb);
});
test('soccer fetch sends soccer markets only', async () => {
await fetchEventOddsFromApi('soccer_fifa_world_cup', 'evt1', 'key', 'soccer_wc');
const [, opts] = mockGet.mock.calls[0];
expect(opts.params.markets).toBe(SM.soccer_wc);
});
test('omitted sport arg falls back to NBA markets (legacy callers)', async () => {
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key');
const [, opts] = mockGet.mock.calls[0];
expect(opts.params.markets).toBe(SM.nba);
});
});
+6 -2
View File
@@ -15,11 +15,15 @@ import withBundleAnalyzer from '@next/bundle-analyzer';
// - Supabase wss: AuthContext realtime + push subscriptions // - Supabase wss: AuthContext realtime + push subscriptions
const CSP = [ const CSP = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com", "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com https://browser.sentry-cdn.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.supabase.co https://cdn.nba.com https://a.espncdn.com", "img-src 'self' data: blob: https://*.supabase.co https://cdn.nba.com https://a.espncdn.com",
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://us.i.posthog.com https://us-assets.i.posthog.com", // Session 16 — Sentry browser client posts events to *.sentry.io
// (and *.ingest.sentry.io for the ingestion endpoints). Adding
// both forms so the @sentry/nextjs init in SentryInit.tsx can
// actually report errors out of the browser bundle.
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://us.i.posthog.com https://us-assets.i.posthog.com https://*.sentry.io https://*.ingest.sentry.io",
"frame-src https://js.stripe.com https://hooks.stripe.com", "frame-src https://js.stripe.com https://hooks.stripe.com",
"worker-src 'self' blob:", "worker-src 'self' blob:",
"manifest-src 'self'", "manifest-src 'self'",
+1 -1
View File
File diff suppressed because one or more lines are too long
+169
View File
@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// Cache the response for 15 minutes (server-side) so cold visitors
// don't trigger a fresh grade on every page load. The cache header
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
export const revalidate = 900;
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HERO_FETCH_TIMEOUT_MS = 6000;
interface OddsResponseProp {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
}
interface OddsResponse {
sport?: string;
source?: string;
props?: OddsResponseProp[];
error?: string;
}
interface GradeResponse {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string; steps?: unknown };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
}
/**
* Live hero prop endpoint (Session 16).
*
* Picks one fresh, real prop from the day's odds and grades it. The
* landing page hero renders this in place of the static Jokic mockup
* — cold visitors see proof of live intelligence on first paint
* instead of a hypothetical example.
*
* Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non-
* empty `props` list first wins. When every sport is empty (off-
* hours, holiday slate, upstream odds quota burned), responds with
* `{ isStatic: true }` and the client falls back to the existing
* static card. Never throws — odds outages must not blank the
* landing page.
*
* Two-stage flow:
* 1. GET ${BACKEND}/api/odds/{sport} → pick random prop
* 2. POST ${BACKEND}/api/analyze/prop → grade it
*
* Both calls share a 6s timeout (AbortController). The overall
* route is wrapped in try/catch and always 200s (with `isStatic:true`
* on failure) so the client renders gracefully.
*/
async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise<Response | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res || !res.ok) return null;
const body = (await res.json().catch(() => null)) as OddsResponse | null;
if (!body || !Array.isArray(body.props) || body.props.length === 0) return null;
// Bias toward A-list player names — props with longer player names
// tend to be top-of-rotation stars (better hero material). Cheap
// heuristic, not a hard filter; we still random-pick among the top
// half of the sorted list so multiple page loads vary.
const sorted = body.props
.filter((p) => p.player && p.stat_type && Number.isFinite(p.line))
.sort((a, b) => (b.player.length - a.player.length));
if (sorted.length === 0) return null;
const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2)));
const pick = topHalf[Math.floor(Math.random() * topHalf.length)];
return { prop: pick, sport };
}
async function gradeProp(sport: string, prop: OddsResponseProp): Promise<GradeResponse | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
sport,
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
direction: prop.direction || 'over',
book: prop.book || 'draftkings',
}),
cache: 'no-store',
});
if (!res || !res.ok) return null;
return (await res.json().catch(() => null)) as GradeResponse | null;
}
export async function GET() {
// The order matters: NBA props lead because mid-summer the cascade
// would otherwise constantly land on the same sport. After NBA
// off-season concludes, swap to a season-aware ordering (winter:
// NBA, summer: MLB + WNBA, fall: NFL — when supported).
const sportsToTry = ['nba', 'wnba', 'mlb'];
try {
for (const sport of sportsToTry) {
const picked = await pickPropFromSport(sport);
if (!picked) continue;
const grade = await gradeProp(picked.sport, picked.prop);
if (!grade) continue;
return NextResponse.json(
{
isStatic: false,
sport: picked.sport,
prop: picked.prop,
grade,
},
{ headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } },
);
}
} catch {
// Falls through to static fallback below.
}
// Static fallback — keeps the hero alive when every sport is empty.
return NextResponse.json(
{
isStatic: true,
sport: 'nba',
prop: {
player: 'Nikola Jokic',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
home_team: 'DEN',
away_team: 'LAL',
},
grade: {
grade: 'A-',
confidence: 73,
edge_pct: 6.2,
projection: 29.4,
reasoning: {
summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.',
},
kill_conditions_triggered: [],
},
},
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } },
);
}
+14 -94
View File
@@ -1,6 +1,10 @@
'use client'; 'use client';
import { GradePill } from './GradeCard'; // Session 16 — the floating demo card on the right side of the hero
// is now driven by /api/hero-prop. Live prop + grade renders with a
// glitch/blur overlay on the reasoning. Falls back to the static
// Jokic layout when no live odds are available.
import LiveHeroProp from './LiveHeroProp';
export default function Hero() { export default function Hero() {
return ( return (
@@ -75,7 +79,7 @@ export default function Hero() {
</p> </p>
</div> </div>
<FloatingDemoCard /> <LiveHeroProp />
</div> </div>
<p <p
style={{ style={{
@@ -173,95 +177,11 @@ function SportBadgeStrip() {
); );
} }
function FloatingDemoCard() { // Session 16 — FloatingDemoCard / Stat / row removed. The hero card
return ( // is now a live, graded prop fetched on mount; see LiveHeroProp.tsx.
<div // The static Jokic layout lives ONCE inside that component as the
className="animate-fade-up stagger-3" // cold-start fallback when /api/hero-prop returns isStatic:true.
style={{ //
position: 'relative', // GradePill (re-exported by GradeCard) is still imported at the top
transform: 'rotate(-1deg)', // of this file because the section header uses it elsewhere; if a
padding: 24, // future cleanup confirms no other usage, that import can drop too.
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
className="mono"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'rgba(233,75,60,0.15)',
color: '#E94B3C',
fontWeight: 700,
}}
>
NBA
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Over 26.5 points
</p>
</div>
<GradePill grade="A-" confidence={73} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value="29.4 pts" />
<Stat label="Edge" value="+6.2%" tone="positive" />
</div>
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
<li style={row}>
<span>Matchup</span>
<span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span>
</li>
<li style={row}>
<span>L10 form</span>
<span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span>
</li>
<li style={row}>
<span>Usage shift</span>
<span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span>
</li>
</ul>
</div>
);
}
const row: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
paddingBlock: 4,
borderBottom: '1px solid var(--border)',
};
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
return (
<div
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-surface)',
borderRadius: 10,
textAlign: 'center',
}}
>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
<div
className="mono"
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
}}
>
{value}
</div>
</div>
);
}
+333
View File
@@ -0,0 +1,333 @@
'use client';
import { useEffect, useState } from 'react';
import { GradePill } from './GradeCard';
/**
* Live hero prop card (Session 16).
*
* Replaces the static Jokic mockup. Fetches /api/hero-prop on mount,
* renders the resulting graded prop, applies a glitch/blur overlay
* on the reasoning section so the grade letter + projection + edge
* are crisp (the hook) but the supporting analysis stays behind a
* paywall (the convert).
*
* Two states:
* - Loading or `isStatic === true` from the API → render the
* existing static layout (kept identical for visual stability
* across the cold-start path).
* - Live prop returned → render real data with the glitch overlay.
*
* Glitch overlay: backdrop-filter blur(4px) + a scan-line gradient
* pseudo-element. CSS keyframes in globals.css ensure mobile gets a
* slower, less-CPU-hungry version (the gradient is static there).
*/
type HeroPropApi = {
isStatic?: boolean;
sport?: string;
prop?: {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
};
grade?: {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
};
};
const SPORT_LABEL: Record<string, string> = {
nba: 'NBA',
wnba: 'WNBA',
mlb: 'MLB',
soccer_wc: 'World Cup',
};
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
wnba: '#FFB347',
mlb: '#1E90FF',
soccer_wc: '#00D4A0',
};
const row: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
paddingBlock: 4,
borderBottom: '1px solid var(--border)',
};
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
return (
<div
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-surface)',
borderRadius: 10,
textAlign: 'center',
}}
>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
<div
className="mono"
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
}}
>
{value}
</div>
</div>
);
}
export default function LiveHeroProp() {
const [data, setData] = useState<HeroPropApi | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let alive = true;
fetch('/api/hero-prop', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((json) => {
if (!alive) return;
setData(json);
setLoaded(true);
})
.catch(() => {
if (!alive) return;
setLoaded(true);
});
return () => { alive = false; };
}, []);
// While loading OR if the API returned the static fallback, render
// the deterministic Jokic layout. Visual continuity matters here —
// cold visitors should see SOMETHING on first paint, then the
// live data slots in once /api/hero-prop returns.
const isLive = loaded && data && !data.isStatic && data.prop && data.grade;
// Pull display fields with safe fallbacks.
const prop = data?.prop;
const grade = data?.grade;
const sport = data?.sport || 'nba';
const matchupLabel = prop?.home_team && prop?.away_team
? `${prop.away_team} @ ${prop.home_team}`
: '—';
const statTypeLabel = (prop?.stat_type || 'points').replace(/_/g, ' ');
const lineDisplay = prop ? `${(prop.direction || 'over').charAt(0).toUpperCase() + (prop.direction || 'over').slice(1)} ${prop.line} ${statTypeLabel}` : '';
// Static-fallback view (the original Jokic card, byte-for-byte
// visually). We render this until the live API returns, then swap.
if (!isLive) {
return (
<div
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
className="mono"
style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: 'rgba(233,75,60,0.15)', color: '#E94B3C', fontWeight: 700,
}}
>
NBA
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Over 26.5 points
</p>
</div>
<GradePill grade="A-" confidence={73} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value="29.4 pts" />
<Stat label="Edge" value="+6.2%" tone="positive" />
</div>
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
<li style={row}><span>Matchup</span><span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span></li>
<li style={row}><span>L10 form</span><span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span></li>
<li style={row}><span>Usage shift</span><span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span></li>
</ul>
</div>
);
}
// Live render.
const gradeText = grade?.grade || 'C';
const confidence = typeof grade?.confidence === 'number' ? Math.round(grade.confidence) : 50;
const projection = typeof grade?.projection === 'number' ? grade.projection.toFixed(1) : '—';
const edge = typeof grade?.edge_pct === 'number' ? grade.edge_pct : 0;
const edgeDisplay = `${edge >= 0 ? '+' : ''}${edge.toFixed(1)}%`;
const reasoning = grade?.reasoning?.summary || '';
const sportLabel = SPORT_LABEL[sport] || sport.toUpperCase();
const sportColor = SPORT_COLOR[sport] || 'var(--grade-a)';
return (
<div
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
{/* LIVE badge — pulsing dot communicates "this was graded just now". */}
<div
className="mono"
aria-label="Live"
style={{
position: 'absolute', top: 12, right: 12,
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 9, color: 'var(--grade-a)', fontWeight: 800, letterSpacing: '0.1em',
}}
>
<span
aria-hidden
style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--grade-a)',
boxShadow: '0 0 8px var(--grade-a)',
}}
/>
LIVE
</div>
{/* Header — visible, the hook. */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, marginTop: 8 }}>
<div>
<span
className="mono"
style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: `${sportColor}26`, color: sportColor, fontWeight: 700,
}}
>
{sportLabel}
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>{prop!.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{lineDisplay}
</p>
</div>
<GradePill grade={gradeText} confidence={confidence} />
</div>
{/* Projection + edge — visible, the proof. */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value={`${projection} ${statTypeLabel}`} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : undefined} />
</div>
{/* Reasoning — BLURRED, the paywall. */}
<div
className="hero-grade-reasoning"
aria-label="Full analysis available after sign up"
style={{
position: 'relative',
padding: 12,
background: 'var(--bg-surface)',
borderRadius: 10,
marginBottom: 12,
overflow: 'hidden',
}}
>
<div
style={{
filter: 'blur(4px)',
userSelect: 'none',
pointerEvents: 'none',
fontSize: 12,
color: 'var(--text-secondary)',
lineHeight: 1.4,
}}
aria-hidden
>
{reasoning || 'Recent form: 28.4 over last 5. Opp defense: top-5 vs PG. Pace: +3.1. Trap composite 0.18. Usage 31%. Kill conditions: 0.'}
</div>
{/* Scan-line overlay — pure CSS gradient pseudo-element via
inline style + position absolute. Subtle on desktop,
disabled on mobile by the @media query below. */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,212,160,0.06) 2px, rgba(0,212,160,0.06) 4px)',
pointerEvents: 'none',
mixBlendMode: 'overlay',
}}
/>
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(18,18,26,0) 30%, rgba(18,18,26,0.85) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
bottom: 6,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 10,
color: 'var(--text-tertiary)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}
className="mono"
>
Classified · Sign up to unlock
</div>
</div>
{/* CTA — drives the conversion the blur sets up. */}
<a
href="/signup"
className="btn-primary"
style={{
display: 'block',
textAlign: 'center',
padding: '10px 16px',
fontSize: 13,
fontWeight: 700,
textDecoration: 'none',
}}
>
Sign up to read the full analysis
</a>
</div>
);
}