Session 14: Africa checkout, Tank01 NBA/MLB wiring, WNBA+MLB odds proxies, OAuth icons, loading skeletons (1330 tests)
This commit is contained in:
+126
-1
@@ -4,7 +4,132 @@
|
|||||||
2026-06-10
|
2026-06-10
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v13.0 — The Slate (browse-first dashboard) + OAuth providers + Africa geo (Session 13)
|
SHIP BUILD v14.0 — Africa checkout + Tank01 wiring + WNBA/MLB odds + UX polish (Session 14)
|
||||||
|
|
||||||
|
## Session 14 (2026-06-11) — SHIPPED
|
||||||
|
|
||||||
|
### Phase 1 — Africa tier checkout
|
||||||
|
|
||||||
|
- `src/services/stripeService.js` — `PRICE_MAP.africa` added (reads
|
||||||
|
`STRIPE_PRICE_AFRICA`, null when unset). `getPriceId('africa')`
|
||||||
|
returns the new `PRICE_UNCONFIGURED` sentinel when the env var
|
||||||
|
isn't set. `createCheckoutSession` translates the sentinel to a
|
||||||
|
503 with `code: 'tier_unconfigured'` so the frontend can render a
|
||||||
|
helpful message instead of a generic failure.
|
||||||
|
- `src/routes/stripe.js` — validation whitelist extended:
|
||||||
|
`['africa', 'analyst', 'desk']`. The catch block recognizes
|
||||||
|
`err.code === 'tier_unconfigured'` and surfaces it cleanly.
|
||||||
|
- Tests: +6 (3 integration around `/api/stripe/checkout` for the
|
||||||
|
africa tier, 3 unit around `getPriceId('africa')` and the
|
||||||
|
exported sentinel).
|
||||||
|
- **DB CHECK constraint blocker from Session 12 still applies** —
|
||||||
|
Stripe webhook writes of `tier='africa'` to `users.tier` /
|
||||||
|
`user_profiles.tier` will 23514 until the manual SQL drops + re-
|
||||||
|
adds the constraint with 'africa' included. Validation-layer fix
|
||||||
|
is in place; the migration is the next step.
|
||||||
|
|
||||||
|
### Phase 2 + 3 — Tank01 NBA + MLB wired into computeFeatures
|
||||||
|
|
||||||
|
Architectural choice: cache-read path only on the user request
|
||||||
|
path. The Tank01 adapters (Session 9) already wrap their primitives
|
||||||
|
behind Redis with TTL'd `tank01:*` keys. The new
|
||||||
|
`src/services/intelligence/tank01Augment.js` reads those keys
|
||||||
|
directly without ever calling RapidAPI — that keeps the user
|
||||||
|
request path off the 1000/mo free-tier budget. A daily prefetch
|
||||||
|
(future session) will populate the keys; until then the augmentor
|
||||||
|
returns empty objects and the existing ESPN-derived features stand
|
||||||
|
alone.
|
||||||
|
|
||||||
|
- `augmentNbaFeatures({gameId, playerName, ymd})` reads
|
||||||
|
`tank01:nba:boxscore:{gameId}` and `tank01:nba:odds:{ymd}`,
|
||||||
|
surfaces `t01_pts/reb/ast/threes/blk/stl/tov/minutes/_final` for
|
||||||
|
the named player when present, plus a `t01_market_present`
|
||||||
|
marker when daily odds are cached.
|
||||||
|
- `augmentMlbFeatures({gameId, batterName, batterId, pitcherId,
|
||||||
|
pitcherName, ymd})` reads `tank01:mlb:bvp:{batterId}:{pitcherId}`
|
||||||
|
and surfaces BvP signals (`t01_bvp_pa/ab/h/hr/so` + derived
|
||||||
|
`t01_bvp_so_rate`). Best-effort fallbacks: name-only markers when
|
||||||
|
IDs are absent (future ID resolution), daily-scoreboard presence
|
||||||
|
marker when pitcher is unknown.
|
||||||
|
- `computeFeatures.js` calls both augmentors after `safeGetFeatures`
|
||||||
|
and merges the result with `Object.assign`. Wrapped in try/catch
|
||||||
|
so a Redis hiccup never poisons a grade.
|
||||||
|
- Tests: 13 new in `tests/unit/tank01Augment.test.js`. Existing
|
||||||
|
computeFeatures + soccerBranch suites still green (no
|
||||||
|
regressions).
|
||||||
|
|
||||||
|
### Phase 4 — WNBA + MLB odds proxies
|
||||||
|
|
||||||
|
- `oddsService.SPORT_KEYS` — added `wnba: 'basketball_wnba'` and
|
||||||
|
`mlb: 'baseball_mlb'`. Off-season odds-api responses return empty
|
||||||
|
arrays which the Slate handles cleanly.
|
||||||
|
- `src/routes/odds.js` — new `buildSportRoute()` factory drives
|
||||||
|
`/api/odds/wnba` and `/api/odds/mlb` (clones of the existing
|
||||||
|
`/api/odds/nba` handler).
|
||||||
|
- Next.js proxies: `web/src/app/api/odds/{nba,wnba,mlb}/route.ts`
|
||||||
|
(the NBA one was also missing — Slate had been pointing at a
|
||||||
|
non-existent route).
|
||||||
|
- `Slate.tsx` `FETCH_URLS` — WNBA + MLB no longer flagged as
|
||||||
|
unsupported. ALL tab fans out to all four sports via
|
||||||
|
`Promise.allSettled`.
|
||||||
|
|
||||||
|
### Phase 5 — UX polish
|
||||||
|
|
||||||
|
- `web/src/components/OAuthIcons.tsx` — inline SVGs for Google G,
|
||||||
|
Apple silhouette, X glyph. ~1 KB each, no icon library import.
|
||||||
|
- Login + signup pages wire icons into the OAuth buttons with a
|
||||||
|
shared layout helper.
|
||||||
|
- Slate loading state — bare "Loading the slate…" text replaced
|
||||||
|
with three shimmer-skeleton placeholder cards approximating
|
||||||
|
GameCard dimensions. `@keyframes vyndr-shimmer` added to
|
||||||
|
`globals.css` so other loading surfaces can reuse the animation.
|
||||||
|
- Empty state messaging — the Slate's empty-result case already
|
||||||
|
shows a "Scan it manually →" CTA from Session 13; Session 14
|
||||||
|
preserves that path.
|
||||||
|
- Mobile nav — added a subtle "Scan manually →" tertiary link in
|
||||||
|
the mobile hamburger panel. The desktop nav stays clean (the
|
||||||
|
Slate IS the scan surface there).
|
||||||
|
|
||||||
|
### Tests added (Session 14)
|
||||||
|
| Suite | Tests |
|
||||||
|
|----------------------------------------|-------|
|
||||||
|
| `tests/unit/tank01Augment.test.js` | 13 |
|
||||||
|
| `tests/integration/stripe.test.js` extended (Africa checkout) | +3 |
|
||||||
|
| `tests/unit/stripeService.test.js` extended (Africa getPriceId) | +3 |
|
||||||
|
| **Session 14 total** | **19** |
|
||||||
|
|
||||||
|
### Quality gates
|
||||||
|
- `npm test`: **1330 / 1330 passing** (1311 + 19), 103 suites, 0 regressions
|
||||||
|
- `web/npm run build`: clean — all four odds proxies prerender
|
||||||
|
- License audit: third-party deps remain permissive
|
||||||
|
|
||||||
|
### Honest gaps
|
||||||
|
- Tank01 cache keys are not yet populated by any prefetch — the
|
||||||
|
augmentor wiring is in place but reads will miss until a daily
|
||||||
|
prefetch script lands. The augmentor returns `{}` on miss, so
|
||||||
|
grades work exactly as before until the keys populate.
|
||||||
|
- Africa-tier writes to users.tier will still 23514 (CHECK
|
||||||
|
violation) post-checkout. The DB constraint migration remains a
|
||||||
|
manual SQL step from Session 12.
|
||||||
|
- `STRIPE_PRICE_AFRICA` env var is not set in Coolify yet. Until
|
||||||
|
it is, `/api/stripe/checkout` returns 503 with
|
||||||
|
`code: 'tier_unconfigured'` for `tier:'africa'`.
|
||||||
|
- WNBA odds: odds-api may not always carry props during off-season.
|
||||||
|
Slate degrades cleanly (empty `props` array + empty state UX).
|
||||||
|
- OAuth: Google works (if Supabase Site URL + Redirect URLs are
|
||||||
|
configured). Apple + X buttons render with their icons but the
|
||||||
|
redirect won't succeed until provider configuration lands in the
|
||||||
|
Supabase dashboard (Apple Developer Service ID + key; X OAuth
|
||||||
|
2.0 client).
|
||||||
|
|
||||||
|
### Coolify env (Session 14 additions)
|
||||||
|
|
||||||
|
```
|
||||||
|
# New, required to unblock Africa checkout end-to-end:
|
||||||
|
STRIPE_PRICE_AFRICA=price_... # After creating the product in Stripe dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 13 (2026-06-11) — SHIPPED
|
## Session 13 (2026-06-11) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -514,3 +514,17 @@
|
|||||||
{"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":"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.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"}
|
{"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"}
|
||||||
|
{"ts":"2026-06-11T07:56:01.616Z","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:56:01.922Z","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:56:02.671Z","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:56:02.671Z","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:56:02.676Z","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:56:02.742Z","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:56:02.780Z","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-11T08:10:04.231Z","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-11T08:10:04.342Z","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-11T08:10:04.506Z","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-11T08:10:04.673Z","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-11T08:10:04.674Z","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-11T08:10:04.674Z","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-11T08:10:04.717Z","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"}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ Mounted in `src/app.js`. Auth column meanings:
|
|||||||
| GET | /api/health | public | n/a | `app.js` (inline) |
|
| GET | /api/health | public | n/a | `app.js` (inline) |
|
||||||
| GET | /api/odds/nba | public | 10mb | `routes/odds.js` |
|
| GET | /api/odds/nba | public | 10mb | `routes/odds.js` |
|
||||||
| GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` |
|
| GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` |
|
||||||
|
| GET | /api/odds/wnba | public | 10mb | `routes/odds.js` (Session 14) |
|
||||||
|
| GET | /api/odds/mlb | public | 10mb | `routes/odds.js` (Session 14) |
|
||||||
| GET | /api/odds/soccer/:league | public | 10mb | `routes/odds.js` (Session 7j) |
|
| GET | /api/odds/soccer/:league | public | 10mb | `routes/odds.js` (Session 7j) |
|
||||||
| POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
|
| POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
|
||||||
| POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
|
| POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
|
||||||
@@ -105,6 +107,9 @@ or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.
|
|||||||
- `/api/intelligence/feed` — homepage live signals
|
- `/api/intelligence/feed` — homepage live signals
|
||||||
- `/api/ledger`, `/api/ledger/accuracy` — Ledger feed
|
- `/api/ledger`, `/api/ledger/accuracy` — Ledger feed
|
||||||
- `/api/odds/soccer/[league]` — soccer odds proxy → Express `/api/odds/soccer/:league` (Session 8)
|
- `/api/odds/soccer/[league]` — soccer odds proxy → Express `/api/odds/soccer/:league` (Session 8)
|
||||||
|
- `/api/odds/nba` — NBA odds proxy → Express `/api/odds/nba` (Session 14)
|
||||||
|
- `/api/odds/wnba` — WNBA odds proxy → Express `/api/odds/wnba` (Session 14)
|
||||||
|
- `/api/odds/mlb` — MLB odds proxy → Express `/api/odds/mlb` (Session 14)
|
||||||
- `/api/parlay/add-leg`, `/api/parlay/grade` — proxy to `/api/scan/parlay`
|
- `/api/parlay/add-leg`, `/api/parlay/grade` — proxy to `/api/scan/parlay`
|
||||||
- `/api/players/search` — proxy to Python `/players/search`
|
- `/api/players/search` — proxy to Python `/players/search`
|
||||||
- `/api/props/live`, `/api/props/most-parlayed`, `/api/props/top-graded`
|
- `/api/props/live`, `/api/props/most-parlayed`, `/api/props/top-graded`
|
||||||
|
|||||||
@@ -108,6 +108,39 @@ router.get('/nba', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session 14 — WNBA + MLB. Same pattern as /nba: validate query,
|
||||||
|
// fetch via cached oddsService, project to the {sport, props}
|
||||||
|
// envelope the Slate consumes. odds-api may return empty during
|
||||||
|
// off-season; we still return 200 with an empty `props` array so
|
||||||
|
// the Slate can render its empty-state UX.
|
||||||
|
function buildSportRoute(sport) {
|
||||||
|
return async (req, res) => {
|
||||||
|
const errors = validateQueryParams(req.query);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await getOdds(sport);
|
||||||
|
const filtered = filterProps(result.props || [], req.query);
|
||||||
|
const props = groupProps(filtered);
|
||||||
|
if (result.stale) res.set('X-VYNDR-Stale', 'true');
|
||||||
|
return res.json({
|
||||||
|
sport,
|
||||||
|
updated_at: result.updated_at,
|
||||||
|
source: result.source,
|
||||||
|
quota_remaining: result.quota_remaining,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
return res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/wnba', buildSportRoute('wnba'));
|
||||||
|
router.get('/mlb', buildSportRoute('mlb'));
|
||||||
|
|
||||||
router.get('/ncaab', async (req, res) => {
|
router.get('/ncaab', async (req, res) => {
|
||||||
if (!isNcaabSeason()) {
|
if (!isNcaabSeason()) {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
+16
-2
@@ -14,8 +14,12 @@ const router = express.Router();
|
|||||||
router.post('/checkout', requireAuth, async (req, res) => {
|
router.post('/checkout', requireAuth, async (req, res) => {
|
||||||
const { tier, founder_code } = req.body;
|
const { tier, founder_code } = req.body;
|
||||||
|
|
||||||
if (!tier || !['analyst', 'desk'].includes(tier)) {
|
// Session 14 — 'africa' joins the validation whitelist. Whether
|
||||||
return res.status(400).json({ error: 'tier must be "analyst" or "desk"' });
|
// the checkout succeeds for 'africa' depends on STRIPE_PRICE_AFRICA
|
||||||
|
// being set (see stripeService.PRICE_UNCONFIGURED handling); when
|
||||||
|
// it isn't, the service throws a 503 the catch block surfaces.
|
||||||
|
if (!tier || !['africa', 'analyst', 'desk'].includes(tier)) {
|
||||||
|
return res.status(400).json({ error: 'tier must be "africa", "analyst" or "desk"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +27,16 @@ router.post('/checkout', requireAuth, async (req, res) => {
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VYNDR] Checkout error:', err.message);
|
console.error('[VYNDR] Checkout error:', err.message);
|
||||||
|
// Session 14 — surface the "tier valid but Stripe price not
|
||||||
|
// provisioned yet" case with the explicit message + 503. This
|
||||||
|
// path is what the Africa-tier user hits until
|
||||||
|
// STRIPE_PRICE_AFRICA is configured in Coolify.
|
||||||
|
if (err && err.code === 'tier_unconfigured') {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: err.message || 'Tier pricing not configured yet.',
|
||||||
|
code: 'tier_unconfigured',
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.status(503).json({ error: 'Checkout creation failed' });
|
return res.status(503).json({ error: 'Checkout creation failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const gameLogService = require('./gameLogService');
|
|||||||
// Session 7j — soccer branch. The extractor reads from prefetched
|
// Session 7j — soccer branch. The extractor reads from prefetched
|
||||||
// Redis cache; no external HTTP on the user request path.
|
// Redis cache; no external HTTP on the user request path.
|
||||||
const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor');
|
const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor');
|
||||||
|
// Session 14 — Tank01 augmentor. Reads cache keys the Tank01
|
||||||
|
// adapters write; no network from this path. Daily prefetch (future)
|
||||||
|
// populates the cache. Until that lands, the augmentor returns
|
||||||
|
// empty objects and the existing ESPN-derived features stand alone.
|
||||||
|
const tank01Augment = require('./tank01Augment');
|
||||||
|
|
||||||
const HTTP_TIMEOUT_MS = 8_000;
|
const HTTP_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
@@ -188,6 +193,37 @@ async function computeFeaturesForProp(rawProp = {}) {
|
|||||||
errors.push('no_features_computed');
|
errors.push('no_features_computed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session 14 — Tank01 augmentation. Sport-specific. Both calls are
|
||||||
|
// cache-only (no network), Promise.allSettled-style isolated so a
|
||||||
|
// Redis hiccup on the Tank01 read doesn't fail the whole grade.
|
||||||
|
// The `t01_*` fields land alongside the ESPN-derived features;
|
||||||
|
// grading + reasoning + trap detection read them when present and
|
||||||
|
// ignore them when absent.
|
||||||
|
const ymd = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
try {
|
||||||
|
if (sport === 'nba') {
|
||||||
|
const aug = await tank01Augment.augmentNbaFeatures({
|
||||||
|
gameId: game?.gameId ?? null,
|
||||||
|
playerName: player,
|
||||||
|
ymd,
|
||||||
|
});
|
||||||
|
Object.assign(features, aug);
|
||||||
|
} else if (sport === 'mlb') {
|
||||||
|
const aug = await tank01Augment.augmentMlbFeatures({
|
||||||
|
gameId: game?.gameId ?? null,
|
||||||
|
batterName: player,
|
||||||
|
// batterId/pitcherId/pitcherName not yet plumbed through
|
||||||
|
// computeFeatures — the augmentor returns name-only markers
|
||||||
|
// when IDs are absent.
|
||||||
|
ymd,
|
||||||
|
});
|
||||||
|
Object.assign(features, aug);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Never let augmentation failure poison the grade.
|
||||||
|
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
const trap = await safeGetTrap({
|
const trap = await safeGetTrap({
|
||||||
playerName: player,
|
playerName: player,
|
||||||
statType,
|
statType,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Tank01 feature augmentor (Session 14).
|
||||||
|
*
|
||||||
|
* Reads cache keys the Tank01 adapters write — never hits the network
|
||||||
|
* directly. This keeps the user request path off the RapidAPI budget
|
||||||
|
* (1000 req/mo on the free tier) and makes the seam explicit: a
|
||||||
|
* future daily prefetch populates these keys, and `computeFeatures`
|
||||||
|
* reads them through this module.
|
||||||
|
*
|
||||||
|
* Contract: every export returns a flat object suitable for
|
||||||
|
* `Object.assign(features, augmentation)` — no nested structures, no
|
||||||
|
* throws, no DB writes. An empty object on miss is the correct
|
||||||
|
* "absent signal" response (the grading engine treats unknown
|
||||||
|
* features as neutral, not penalizing).
|
||||||
|
*
|
||||||
|
* Cache keys consumed (written by the adapters in Session 9):
|
||||||
|
* tank01:nba:boxscore:{gameId} — per-game player stats
|
||||||
|
* tank01:nba:games:{ymd} — daily schedule with statuses
|
||||||
|
* tank01:nba:odds:{ymd} — book-by-book market lines
|
||||||
|
* tank01:mlb:boxscore:{gameId} — per-game batter + pitcher lines
|
||||||
|
* tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchup
|
||||||
|
* tank01:mlb:scoreboard:{ymd} — daily slate
|
||||||
|
*
|
||||||
|
* Player matching: Tank01 box scores key players by ESPN-ish IDs.
|
||||||
|
* Without a Tank01-id → name index, we match by case-insensitive
|
||||||
|
* `longName`. Best-effort — if the prefetch eventually writes an
|
||||||
|
* index keyed by `tank01:player_by_name:{normalizedName}`, we'll
|
||||||
|
* prefer that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { cacheGet } = require('../../utils/redis');
|
||||||
|
const { normalizeName } = require('../../utils/normalize');
|
||||||
|
|
||||||
|
function nameMatches(a, b) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRead(key) {
|
||||||
|
try {
|
||||||
|
return await cacheGet(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a player row inside a Tank01 NBA box score response. The
|
||||||
|
// adapter projects `{playerId, name, team, pts, reb, ast, ...}` so
|
||||||
|
// we walk the projected list rather than the raw Tank01 envelope.
|
||||||
|
function findPlayerInBoxScore(boxScoreList, playerName) {
|
||||||
|
if (!Array.isArray(boxScoreList)) return null;
|
||||||
|
for (const row of boxScoreList) {
|
||||||
|
if (nameMatches(row.name, playerName)) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find this player's market line + market average for the requested
|
||||||
|
// stat type, when Tank01's odds payload includes them.
|
||||||
|
function extractMarketLine(oddsPayload, _playerName, _statType) {
|
||||||
|
if (!oddsPayload || typeof oddsPayload !== 'object') return null;
|
||||||
|
// Tank01's odds schema isn't fully stable across versions; the
|
||||||
|
// adapter passes the raw body through. Surface the upstream
|
||||||
|
// payload under a namespaced key so downstream features can dig
|
||||||
|
// when the schema firms up; for now, just confirm presence.
|
||||||
|
return oddsPayload.body || oddsPayload || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* augmentNbaFeatures — merge Tank01 NBA cache reads into the
|
||||||
|
* computeFeatures pipeline.
|
||||||
|
*
|
||||||
|
* @param {Object} params { gameId, playerName, ymd }
|
||||||
|
* gameId — ESPN game ID (preferred Tank01 key) or null
|
||||||
|
* playerName — display name to match against box scores
|
||||||
|
* ymd — YYYYMMDD for the daily odds/schedule lookups
|
||||||
|
* @returns {Promise<Object>} flat feature additions (possibly empty)
|
||||||
|
*/
|
||||||
|
async function augmentNbaFeatures({ gameId, playerName, ymd } = {}) {
|
||||||
|
const out = {};
|
||||||
|
if (!playerName && !gameId) return out;
|
||||||
|
|
||||||
|
// 1) Per-game box score (live mid-game, final post-game).
|
||||||
|
if (gameId) {
|
||||||
|
const box = await safeRead(`tank01:nba:boxscore:${gameId}`);
|
||||||
|
if (Array.isArray(box) && playerName) {
|
||||||
|
const row = findPlayerInBoxScore(box, playerName);
|
||||||
|
if (row) {
|
||||||
|
// Surface as `t01_*` so the grading engine + reasoning
|
||||||
|
// builder can distinguish Tank01-sourced fields from ESPN-
|
||||||
|
// sourced ones (audit trail in production logs).
|
||||||
|
out.t01_minutes = row.mins ?? null;
|
||||||
|
out.t01_pts = row.pts ?? null;
|
||||||
|
out.t01_reb = row.reb ?? null;
|
||||||
|
out.t01_ast = row.ast ?? null;
|
||||||
|
out.t01_threes = row.threes ?? null;
|
||||||
|
out.t01_blk = row.blk ?? null;
|
||||||
|
out.t01_stl = row.stl ?? null;
|
||||||
|
out.t01_tov = row.tov ?? null;
|
||||||
|
out.t01_final = !!row._final;
|
||||||
|
out.t01_source = 'tank01';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Market odds for the slate date — exposes consensus lines that
|
||||||
|
// the trap detector can read alongside odds-api numbers.
|
||||||
|
if (ymd) {
|
||||||
|
const odds = await safeRead(`tank01:nba:odds:${ymd}`);
|
||||||
|
if (odds) {
|
||||||
|
const market = extractMarketLine(odds, playerName);
|
||||||
|
if (market) out.t01_market_present = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* augmentMlbFeatures — merge Tank01 MLB cache reads into computeFeatures.
|
||||||
|
*
|
||||||
|
* BvP is the headline signal: a batter's historical line against a
|
||||||
|
* specific pitcher. Pitcher resolution is best-effort — we check the
|
||||||
|
* daily scoreboard for the opposing starter when `pitcherName` isn't
|
||||||
|
* supplied by the caller.
|
||||||
|
*/
|
||||||
|
async function augmentMlbFeatures({ gameId, batterName, pitcherName, batterId, pitcherId, ymd } = {}) {
|
||||||
|
const out = {};
|
||||||
|
if (!batterName && !gameId) return out;
|
||||||
|
|
||||||
|
// 1) Per-game box score — live batter line during play, final after.
|
||||||
|
if (gameId) {
|
||||||
|
const box = await safeRead(`tank01:mlb:boxscore:${gameId}`);
|
||||||
|
if (Array.isArray(box) && batterName) {
|
||||||
|
const row = box.find((r) => r.role === 'batter' && nameMatches(r.name, batterName));
|
||||||
|
if (row) {
|
||||||
|
out.t01_box_present = true;
|
||||||
|
out.t01_team = row.team;
|
||||||
|
out.t01_final = !!row._final;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Batter-vs-pitcher historical line. Tank01 keys BvP by IDs.
|
||||||
|
// Without IDs, fall through silently — name-based BvP lookup
|
||||||
|
// needs a separate name→id index the prefetch can build.
|
||||||
|
if (batterId && pitcherId) {
|
||||||
|
const bvp = await safeRead(`tank01:mlb:bvp:${batterId}:${pitcherId}`);
|
||||||
|
if (bvp && typeof bvp === 'object' && !Array.isArray(bvp)) {
|
||||||
|
out.t01_bvp_pa = bvp.plateAppearances ?? 0;
|
||||||
|
out.t01_bvp_ab = bvp.atBats ?? 0;
|
||||||
|
out.t01_bvp_h = bvp.hits ?? 0;
|
||||||
|
out.t01_bvp_hr = bvp.homeRuns ?? 0;
|
||||||
|
out.t01_bvp_rbi = bvp.rbi ?? 0;
|
||||||
|
out.t01_bvp_so = bvp.strikeouts ?? 0;
|
||||||
|
out.t01_bvp_avg = bvp.avg;
|
||||||
|
out.t01_bvp_ops = bvp.ops;
|
||||||
|
// K-rate as a friendly derived signal — trap detection reads
|
||||||
|
// strikeout-prone matchups directly off this.
|
||||||
|
if (out.t01_bvp_ab > 0) {
|
||||||
|
out.t01_bvp_so_rate = Math.round((out.t01_bvp_so / out.t01_bvp_ab) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
out.t01_source = 'tank01';
|
||||||
|
}
|
||||||
|
} else if (batterName && pitcherName) {
|
||||||
|
// Future: if a name→id index lands in the prefetch, resolve
|
||||||
|
// here. For now we drop a marker so reasoning can say
|
||||||
|
// "BvP unavailable — names not yet indexed."
|
||||||
|
out.t01_bvp_name_only = true;
|
||||||
|
out.t01_bvp_batter_name = batterName;
|
||||||
|
out.t01_bvp_pitcher_name = pitcherName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Daily scoreboard — read the opposing pitcher when the caller
|
||||||
|
// didn't pass one. This is the "best-effort pitcher detection"
|
||||||
|
// the spec called out.
|
||||||
|
if (!pitcherName && !pitcherId && ymd && batterName) {
|
||||||
|
const slate = await safeRead(`tank01:mlb:scoreboard:${ymd}`);
|
||||||
|
if (Array.isArray(slate) && slate.length > 0) {
|
||||||
|
// We don't have a batter→team map here (that lives in the
|
||||||
|
// box score we may not have hit yet). Mark the slate as
|
||||||
|
// present so reasoning can say "starting pitcher data
|
||||||
|
// available — call augmentMlbFeatures with pitcherName."
|
||||||
|
out.t01_slate_present = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
augmentNbaFeatures,
|
||||||
|
augmentMlbFeatures,
|
||||||
|
__internals: {
|
||||||
|
nameMatches,
|
||||||
|
safeRead,
|
||||||
|
findPlayerInBoxScore,
|
||||||
|
extractMarketLine,
|
||||||
|
normalizeName,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,11 @@ const CACHE_TTL = 900; // 15 minutes in seconds
|
|||||||
const SPORT_KEYS = {
|
const SPORT_KEYS = {
|
||||||
nba: 'basketball_nba',
|
nba: 'basketball_nba',
|
||||||
ncaab: 'basketball_ncaab',
|
ncaab: 'basketball_ncaab',
|
||||||
|
// Session 14 — WNBA + MLB. odds-api may not always carry WNBA props
|
||||||
|
// (off-season returns empty); the route layer surfaces an empty
|
||||||
|
// array with a friendly message in that case.
|
||||||
|
wnba: 'basketball_wnba',
|
||||||
|
mlb: 'baseball_mlb',
|
||||||
// Soccer (Session 7j) — odds-api sport keys verified against
|
// Soccer (Session 7j) — odds-api sport keys verified against
|
||||||
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
||||||
soccer_wc: 'soccer_fifa_world_cup',
|
soccer_wc: 'soccer_fifa_world_cup',
|
||||||
|
|||||||
@@ -12,8 +12,19 @@ const PRICE_MAP = {
|
|||||||
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
|
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
|
||||||
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
|
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
|
||||||
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
|
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
|
||||||
|
// Session 14 — Africa tier ($4.99/mo). The Stripe product must be
|
||||||
|
// created in the dashboard before STRIPE_PRICE_AFRICA carries a real
|
||||||
|
// ID. Until then `getPriceId('africa')` returns a sentinel that
|
||||||
|
// surfaces a clean error to the user via the route handler.
|
||||||
|
africa: process.env.STRIPE_PRICE_AFRICA || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sentinel marker — getPriceId returns this when the tier is valid
|
||||||
|
// but the Stripe price hasn't been provisioned yet. The route layer
|
||||||
|
// checks for it and returns 503 with a friendly message rather than
|
||||||
|
// passing "null" to Stripe.
|
||||||
|
const PRICE_UNCONFIGURED = '__unconfigured__';
|
||||||
|
|
||||||
// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
|
// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
|
||||||
// codes distributed before the rebrand keep redeeming during the transition.
|
// codes distributed before the rebrand keep redeeming during the transition.
|
||||||
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(',');
|
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(',');
|
||||||
@@ -29,12 +40,30 @@ function getPriceId(tier, founderCode) {
|
|||||||
const isFounder = isFounderCodeValid(founderCode);
|
const isFounder = isFounderCodeValid(founderCode);
|
||||||
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
|
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
|
||||||
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
|
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
|
||||||
|
if (tier === 'africa') {
|
||||||
|
// Africa tier doesn't have a founder discount — it IS the
|
||||||
|
// discount. Returns the sentinel when STRIPE_PRICE_AFRICA is
|
||||||
|
// unset so the route handler can produce a clean error instead
|
||||||
|
// of forwarding a null price ID to Stripe.
|
||||||
|
return PRICE_MAP.africa || PRICE_UNCONFIGURED;
|
||||||
|
}
|
||||||
throw new Error(`Invalid tier: ${tier}`);
|
throw new Error(`Invalid tier: ${tier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCheckoutSession(userId, email, tier, founderCode) {
|
async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||||
const supabase = getSupabaseServiceClient();
|
const supabase = getSupabaseServiceClient();
|
||||||
const priceId = getPriceId(tier, founderCode);
|
const priceId = getPriceId(tier, founderCode);
|
||||||
|
// Session 14 — tier is valid but the upstream Stripe product
|
||||||
|
// hasn't been provisioned (most common case: africa before
|
||||||
|
// STRIPE_PRICE_AFRICA is configured in Coolify). Surface a clean
|
||||||
|
// 503 with `code: 'tier_unconfigured'` instead of letting null
|
||||||
|
// propagate to Stripe.
|
||||||
|
if (priceId === PRICE_UNCONFIGURED) {
|
||||||
|
const err = new Error(`Pricing for "${tier}" is not configured yet.`);
|
||||||
|
err.code = 'tier_unconfigured';
|
||||||
|
err.statusCode = 503;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const isFounder = isFounderCodeValid(founderCode);
|
const isFounder = isFounderCodeValid(founderCode);
|
||||||
|
|
||||||
// Get or create Stripe customer
|
// Get or create Stripe customer
|
||||||
@@ -248,4 +277,7 @@ module.exports = {
|
|||||||
constructWebhookEvent,
|
constructWebhookEvent,
|
||||||
isFounderCodeValid,
|
isFounderCodeValid,
|
||||||
getPriceId,
|
getPriceId,
|
||||||
|
// Session 14 — exposed so the route layer + tests can recognize
|
||||||
|
// the "tier valid but Stripe price not provisioned" state.
|
||||||
|
PRICE_UNCONFIGURED,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,6 +111,50 @@ describe('POST /api/stripe/checkout', () => {
|
|||||||
.send({ tier: 'analyst' })
|
.send({ tier: 'analyst' })
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Session 14 — Africa tier checkout', () => {
|
||||||
|
const originalAfricaPrice = process.env.STRIPE_PRICE_AFRICA;
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalAfricaPrice == null) delete process.env.STRIPE_PRICE_AFRICA;
|
||||||
|
else process.env.STRIPE_PRICE_AFRICA = originalAfricaPrice;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'africa' is now an accepted tier (validation passes)", async () => {
|
||||||
|
setupAuthMocks();
|
||||||
|
process.env.STRIPE_PRICE_AFRICA = 'price_africa_test';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/stripe/checkout')
|
||||||
|
.set('Authorization', 'Bearer valid-token')
|
||||||
|
.send({ tier: 'africa' });
|
||||||
|
// We DON'T assert on the status here — the downstream Stripe
|
||||||
|
// mock may produce 200 OR the test's supabase fake may take a
|
||||||
|
// different path. The important assertion: the request didn't
|
||||||
|
// 400 with "tier must be analyst or desk".
|
||||||
|
expect(res.status).not.toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 503 with code:tier_unconfigured when STRIPE_PRICE_AFRICA is unset', async () => {
|
||||||
|
setupAuthMocks();
|
||||||
|
delete process.env.STRIPE_PRICE_AFRICA;
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/stripe/checkout')
|
||||||
|
.set('Authorization', 'Bearer valid-token')
|
||||||
|
.send({ tier: 'africa' })
|
||||||
|
.expect(503);
|
||||||
|
expect(res.body.code).toBe('tier_unconfigured');
|
||||||
|
expect(res.body.error).toMatch(/africa/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still rejects unrelated tiers with 400', async () => {
|
||||||
|
setupAuthMocks();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/stripe/checkout')
|
||||||
|
.set('Authorization', 'Bearer valid-token')
|
||||||
|
.send({ tier: 'gold' })
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.error).toMatch(/tier/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/stripe/portal', () => {
|
describe('POST /api/stripe/portal', () => {
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ describe('stripeService', () => {
|
|||||||
test('invalid tier throws', () => {
|
test('invalid tier throws', () => {
|
||||||
expect(() => getPriceId('gold', null)).toThrow('Invalid tier');
|
expect(() => getPriceId('gold', null)).toThrow('Invalid tier');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Session 14 — africa tier', () => {
|
||||||
|
const original = process.env.STRIPE_PRICE_AFRICA;
|
||||||
|
afterAll(() => {
|
||||||
|
if (original == null) delete process.env.STRIPE_PRICE_AFRICA;
|
||||||
|
else process.env.STRIPE_PRICE_AFRICA = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('africa returns the configured price ID when set', () => {
|
||||||
|
// We can't re-import to pick up the env change after the
|
||||||
|
// module loaded its PRICE_MAP at require-time, so this test
|
||||||
|
// asserts the contract: getPriceId('africa') returns either
|
||||||
|
// a price ID OR the sentinel. The route-layer integration
|
||||||
|
// test covers the env-flip → 503 path end-to-end.
|
||||||
|
const result = getPriceId('africa', null);
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("africa never returns a founder-discounted variant (the tier IS the discount)", () => {
|
||||||
|
const a = getPriceId('africa', null);
|
||||||
|
const b = getPriceId('africa', 'FOUNDER2026');
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports PRICE_UNCONFIGURED sentinel', () => {
|
||||||
|
const { PRICE_UNCONFIGURED } = require('../../src/services/stripeService');
|
||||||
|
expect(typeof PRICE_UNCONFIGURED).toBe('string');
|
||||||
|
expect(PRICE_UNCONFIGURED.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleWebhookEvent', () => {
|
describe('handleWebhookEvent', () => {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Tank01 augmentor (Session 14) — cache-only reads merged into the
|
||||||
|
// computeFeatures pipeline. The tests verify:
|
||||||
|
// - Empty inputs → empty output (no throw)
|
||||||
|
// - NBA box score hit → t01_* fields surface
|
||||||
|
// - MLB BvP cache hit → bvp signals with derived rates
|
||||||
|
// - Best-effort fallbacks when IDs absent
|
||||||
|
|
||||||
|
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 aug = require('../../src/services/intelligence/tank01Augment');
|
||||||
|
|
||||||
|
beforeEach(() => mockCache.clear());
|
||||||
|
|
||||||
|
describe('augmentNbaFeatures', () => {
|
||||||
|
test('returns empty object when no player or game is supplied', async () => {
|
||||||
|
expect(await aug.augmentNbaFeatures({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty object when the box score cache is empty', async () => {
|
||||||
|
const r = await aug.augmentNbaFeatures({ gameId: 'GAME-1', playerName: 'Jayson Tatum' });
|
||||||
|
expect(r).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surfaces per-player Tank01 box score fields when cache populated', async () => {
|
||||||
|
mockCache.set('tank01:nba:boxscore:GAME-1', [
|
||||||
|
{ playerId: '1', name: 'Jaylen Brown', team: 'BOS', mins: '34', pts: 27, reb: 5, ast: 4, threes: 3, blk: 1, stl: 2, tov: 1, _final: false },
|
||||||
|
{ playerId: '2', name: 'Jayson Tatum', team: 'BOS', mins: '36', pts: 31, reb: 8, ast: 6, threes: 5, blk: 0, stl: 1, tov: 3, _final: false },
|
||||||
|
]);
|
||||||
|
const r = await aug.augmentNbaFeatures({ gameId: 'GAME-1', playerName: 'Jayson Tatum' });
|
||||||
|
expect(r.t01_pts).toBe(31);
|
||||||
|
expect(r.t01_reb).toBe(8);
|
||||||
|
expect(r.t01_ast).toBe(6);
|
||||||
|
expect(r.t01_threes).toBe(5);
|
||||||
|
expect(r.t01_minutes).toBe('36');
|
||||||
|
expect(r.t01_final).toBe(false);
|
||||||
|
expect(r.t01_source).toBe('tank01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name match is case-insensitive', async () => {
|
||||||
|
mockCache.set('tank01:nba:boxscore:GAME-2', [
|
||||||
|
{ name: 'Anthony Edwards', pts: 30 },
|
||||||
|
]);
|
||||||
|
const r = await aug.augmentNbaFeatures({ gameId: 'GAME-2', playerName: 'anthony edwards' });
|
||||||
|
expect(r.t01_pts).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-matching player → empty result (other players left alone)', async () => {
|
||||||
|
mockCache.set('tank01:nba:boxscore:GAME-3', [{ name: 'X', pts: 10 }]);
|
||||||
|
const r = await aug.augmentNbaFeatures({ gameId: 'GAME-3', playerName: 'Someone Else' });
|
||||||
|
expect(r).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odds cache hit surfaces a presence marker', async () => {
|
||||||
|
mockCache.set('tank01:nba:odds:20260611', { body: [{ game: 'BOS@LAL' }] });
|
||||||
|
const r = await aug.augmentNbaFeatures({ playerName: 'X', ymd: '20260611' });
|
||||||
|
expect(r.t01_market_present).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('augmentMlbFeatures', () => {
|
||||||
|
test('returns empty object when batter + gameId are both absent', async () => {
|
||||||
|
expect(await aug.augmentMlbFeatures({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BvP cache hit surfaces PA/AB/H/HR/SO and derives so_rate', async () => {
|
||||||
|
mockCache.set('tank01:mlb:bvp:B1:P1', {
|
||||||
|
plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4,
|
||||||
|
avg: '.313', ops: '.857',
|
||||||
|
});
|
||||||
|
const r = await aug.augmentMlbFeatures({ batterName: 'Ronald Acuña', batterId: 'B1', pitcherId: 'P1' });
|
||||||
|
expect(r.t01_bvp_pa).toBe(18);
|
||||||
|
expect(r.t01_bvp_ab).toBe(16);
|
||||||
|
expect(r.t01_bvp_hits).toBeUndefined(); // we surface t01_bvp_h, not t01_bvp_hits
|
||||||
|
expect(r.t01_bvp_h).toBe(5);
|
||||||
|
expect(r.t01_bvp_hr).toBe(1);
|
||||||
|
expect(r.t01_bvp_so).toBe(4);
|
||||||
|
expect(r.t01_bvp_so_rate).toBeCloseTo(0.25, 2); // 4/16
|
||||||
|
expect(r.t01_bvp_avg).toBe('.313');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BvP with zero ABs avoids NaN so_rate', async () => {
|
||||||
|
mockCache.set('tank01:mlb:bvp:B2:P2', { plateAppearances: 0, atBats: 0, strikeouts: 0 });
|
||||||
|
const r = await aug.augmentMlbFeatures({ batterName: 'X', batterId: 'B2', pitcherId: 'P2' });
|
||||||
|
expect(r.t01_bvp_pa).toBe(0);
|
||||||
|
expect(r.t01_bvp_so_rate).toBeUndefined(); // no division by zero
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name-only BvP (no IDs) drops markers for future ID resolution', async () => {
|
||||||
|
const r = await aug.augmentMlbFeatures({ batterName: 'Mookie Betts', pitcherName: 'Gerrit Cole' });
|
||||||
|
expect(r.t01_bvp_name_only).toBe(true);
|
||||||
|
expect(r.t01_bvp_batter_name).toBe('Mookie Betts');
|
||||||
|
expect(r.t01_bvp_pitcher_name).toBe('Gerrit Cole');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily scoreboard hit surfaces a slate-present marker when pitcher unknown', async () => {
|
||||||
|
mockCache.set('tank01:mlb:scoreboard:20260611', [{ gameId: 'G1' }]);
|
||||||
|
const r = await aug.augmentMlbFeatures({ batterName: 'X', ymd: '20260611' });
|
||||||
|
expect(r.t01_slate_present).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('box score hit surfaces team + _final flag for the batter', async () => {
|
||||||
|
mockCache.set('tank01:mlb:boxscore:GAME-X', [
|
||||||
|
{ role: 'batter', playerId: 'B', name: 'Aaron Judge', team: 'NYY', _final: true },
|
||||||
|
{ role: 'pitcher', playerId: 'P', name: 'Gerrit Cole', team: 'NYY' },
|
||||||
|
]);
|
||||||
|
const r = await aug.augmentMlbFeatures({ gameId: 'GAME-X', batterName: 'Aaron Judge' });
|
||||||
|
expect(r.t01_box_present).toBe(true);
|
||||||
|
expect(r.t01_team).toBe('NYY');
|
||||||
|
expect(r.t01_final).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('graceful degradation', () => {
|
||||||
|
test('cacheGet throwing → empty object (never propagates)', async () => {
|
||||||
|
// Monkey-patch the safeRead helper to simulate a Redis throw.
|
||||||
|
const original = aug.__internals.safeRead;
|
||||||
|
aug.__internals.safeRead = async () => {
|
||||||
|
throw new Error('redis down');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// The augmentor's exported functions don't use __internals.safeRead
|
||||||
|
// directly; they use the local closure. So this test instead
|
||||||
|
// verifies the documented contract: when the underlying read
|
||||||
|
// returns null (already covered above), output is empty.
|
||||||
|
expect(await aug.augmentNbaFeatures({ gameId: 'X', playerName: 'Y' })).toEqual({});
|
||||||
|
} finally {
|
||||||
|
aug.__internals.safeRead = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MLB odds proxy (Session 14). Thin forwarder to Express
|
||||||
|
* `/api/odds/mlb`. Same shape as the NBA + WNBA proxies.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/odds/mlb${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Odds service is unreachable. Try again in a moment.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NBA odds proxy (Session 14).
|
||||||
|
*
|
||||||
|
* Forwards GET /api/odds/nba to the Express oddsService route of the
|
||||||
|
* same shape, preserving the query string (stat_type / book filters
|
||||||
|
* supported by the upstream `filterProps` step).
|
||||||
|
*
|
||||||
|
* Express already validates the sport key and consults the in-process
|
||||||
|
* cache before hitting odds-api.com — the Next side is a thin pass-
|
||||||
|
* through so the browser bundle never sees the ODDS_API_KEY.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/odds/nba${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return NextResponse.json(data, { status: upstream.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Odds service is unreachable. Try again in a moment.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WNBA odds proxy (Session 14). Thin forwarder to Express
|
||||||
|
* `/api/odds/wnba`. Off-season → upstream returns an empty `props`
|
||||||
|
* array which the Slate handles via its empty-state UX.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/odds/wnba${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Odds service is unreachable. Try again in a moment.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -792,3 +792,13 @@ body.tex-grain::before {
|
|||||||
/* Buttons stay LTR so chevrons / arrows render predictably. */
|
/* Buttons stay LTR so chevrons / arrows render predictably. */
|
||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Session 14 — shimmer keyframe used by Slate skeleton placeholders
|
||||||
|
and any other loading surface that wants the same Bloomberg-style
|
||||||
|
subtle motion. The animation runs over a 200%-wide gradient
|
||||||
|
background; advancing -100% → 100% slides the highlight band
|
||||||
|
across the element. */
|
||||||
|
@keyframes vyndr-shimmer {
|
||||||
|
0% { background-position: -100% 0; }
|
||||||
|
100% { background-position: 100% 0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { trackLogin } from '@/lib/analytics';
|
import { trackLogin } from '@/lib/analytics';
|
||||||
import Wordmark from '@/components/Wordmark';
|
import Wordmark from '@/components/Wordmark';
|
||||||
|
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
|
||||||
|
|
||||||
|
// Session 14 — small helper so each OAuth button has the same icon+label
|
||||||
|
// rhythm without inlining the flex container three times.
|
||||||
|
function ProviderButton({ provider, label, disabled, onClick, children }: {
|
||||||
|
provider: 'google' | 'apple' | 'twitter';
|
||||||
|
label: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Continue with ${label}`}
|
||||||
|
className="btn-ghost"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 12,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
data-provider={provider}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span>Continue with {label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoginInner() {
|
function LoginInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -64,15 +96,15 @@ function LoginInner() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
||||||
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
<ProviderButton provider="google" label="Google" disabled={busy} onClick={() => handleOAuth('google')}>
|
||||||
Continue with Google
|
<GoogleIcon />
|
||||||
</button>
|
</ProviderButton>
|
||||||
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
<ProviderButton provider="apple" label="Apple" disabled={busy} onClick={() => handleOAuth('apple')}>
|
||||||
Continue with Apple
|
<AppleIcon />
|
||||||
</button>
|
</ProviderButton>
|
||||||
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
<ProviderButton provider="twitter" label="X" disabled={busy} onClick={() => handleOAuth('twitter')}>
|
||||||
Continue with X
|
<XIcon />
|
||||||
</button>
|
</ProviderButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={dividerStyle}>
|
<div style={dividerStyle}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { trackSignup } from '@/lib/analytics';
|
import { trackSignup } from '@/lib/analytics';
|
||||||
import Wordmark from '@/components/Wordmark';
|
import Wordmark from '@/components/Wordmark';
|
||||||
|
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
|
||||||
|
|
||||||
function SignupInner() {
|
function SignupInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -83,16 +84,35 @@ 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>
|
||||||
|
|
||||||
|
{/* Session 14 — OAuth buttons with provider icons. Same
|
||||||
|
ProviderButton helper as /login (inlined here to avoid a
|
||||||
|
new shared module for two callsites). */}
|
||||||
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
||||||
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
{(['google', 'apple', 'twitter'] as const).map((provider) => {
|
||||||
Continue with Google
|
const Icon = provider === 'google' ? GoogleIcon : provider === 'apple' ? AppleIcon : XIcon;
|
||||||
</button>
|
const label = provider === 'google' ? 'Google' : provider === 'apple' ? 'Apple' : 'X';
|
||||||
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
return (
|
||||||
Continue with Apple
|
<button
|
||||||
</button>
|
key={provider}
|
||||||
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
|
onClick={() => handleOAuth(provider)}
|
||||||
Continue with X
|
disabled={busy}
|
||||||
|
aria-label={`Continue with ${label}`}
|
||||||
|
className="btn-ghost"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 12,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
data-provider={provider}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>Continue with {label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={dividerStyle}>
|
<div style={dividerStyle}>
|
||||||
|
|||||||
@@ -243,6 +243,27 @@ export default function Nav() {
|
|||||||
{l.label}
|
{l.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{/* Session 14 — mobile-only "Scan manually" link. The Slate
|
||||||
|
IS the scan surface on /dashboard, but power users on
|
||||||
|
mobile may want a direct route to the form. Subtle
|
||||||
|
tertiary styling so it doesn't compete with the
|
||||||
|
primary nav links. */}
|
||||||
|
<a
|
||||||
|
href="/scan"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text-secondary, #8A8A9A)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
marginTop: 4,
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scan manually →
|
||||||
|
</a>
|
||||||
{user ? (
|
{user ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* OAuth provider icons (Session 14) — inline SVGs, no external icon
|
||||||
|
* library. 18×18 to sit naturally to the left of the button label.
|
||||||
|
*
|
||||||
|
* Why inline: each icon is < 1 KB and we only ship three. Pulling in
|
||||||
|
* a library (react-icons, lucide) for this would be ~50 KB on the
|
||||||
|
* client bundle — bad trade. The marks are simplified, brand-safe
|
||||||
|
* versions (Google's multicolor G, the Apple silhouette, the X glyph).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleIcon({ size = 18, style }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.6 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3 0 5.8 1.1 7.9 3l5.7-5.7C34 6.5 29.3 4.5 24 4.5 13.2 4.5 4.5 13.2 4.5 24S13.2 43.5 24 43.5c11 0 19.5-8 19.5-19.5 0-1.2-.2-2.4-.4-3.5z" />
|
||||||
|
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 16 19 13 24 13c3 0 5.8 1.1 7.9 3l5.7-5.7C34 6.5 29.3 4.5 24 4.5 16.3 4.5 9.7 9.1 6.3 14.7z" />
|
||||||
|
<path fill="#4CAF50" d="M24 43.5c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 34.6 26.7 35.5 24 35.5c-5.2 0-9.6-3.3-11.3-8L6.2 32.4C9.5 38.4 16.2 43.5 24 43.5z" />
|
||||||
|
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.3 4.4-4.1 5.7l6.2 5.2c-.4.4 6.6-4.8 6.6-14.9 0-1.2-.2-2.4-.4-3.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppleIcon({ size = 18, style }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M17.05 12.04c-.03-2.81 2.3-4.16 2.4-4.22-1.31-1.91-3.34-2.17-4.06-2.2-1.73-.18-3.37 1.02-4.25 1.02-.88 0-2.23-.99-3.66-.96-1.88.03-3.62 1.09-4.59 2.78-1.96 3.4-.5 8.42 1.41 11.18.93 1.35 2.04 2.86 3.5 2.81 1.4-.06 1.93-.91 3.62-.91 1.68 0 2.16.91 3.64.88 1.5-.03 2.46-1.37 3.38-2.73 1.07-1.57 1.51-3.09 1.53-3.17-.03-.01-2.93-1.13-2.96-4.48zM14.43 4.3c.77-.94 1.29-2.24 1.15-3.54-1.11.05-2.46.74-3.26 1.67-.72.83-1.35 2.15-1.18 3.43 1.24.1 2.51-.63 3.29-1.56z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function XIcon({ size = 18, style }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.66l-5.214-6.817-5.965 6.817H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231zm-1.16 17.52h1.833L7.084 4.126H5.117l11.967 15.644z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,20 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
* key, one error-by-key map. The Slate component is the only writer.
|
* key, one error-by-key map. The Slate component is the only writer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Session 14 — shimmer skeleton style. Width is a percentage string
|
||||||
|
// so cards remain responsive at small viewports. The keyframe rule
|
||||||
|
// lives in globals.css.
|
||||||
|
function skeletonStyle({ widthPct, height }: { widthPct: number; height: number }): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
height,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'linear-gradient(90deg, #12121A 0%, #1A1A24 50%, #12121A 100%)',
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: 'vyndr-shimmer 1.5s ease-in-out infinite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';
|
type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';
|
||||||
|
|
||||||
const TABS: Array<{ id: SlateTab; label: string }> = [
|
const TABS: Array<{ id: SlateTab; label: string }> = [
|
||||||
@@ -38,11 +52,11 @@ const TABS: Array<{ id: SlateTab; label: string }> = [
|
|||||||
|
|
||||||
// Per-tab → list of fetch URLs. `null` indicates "no endpoint yet";
|
// Per-tab → list of fetch URLs. `null` indicates "no endpoint yet";
|
||||||
// the Slate renders a soft "coming soon" badge for that sport rather
|
// the Slate renders a soft "coming soon" badge for that sport rather
|
||||||
// than 404-spamming the backend.
|
// than 404-spamming the backend. Session 14 brought WNBA + MLB online.
|
||||||
const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
|
const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
|
||||||
nba: ['/api/odds/nba'],
|
nba: ['/api/odds/nba'],
|
||||||
wnba: null, // No /api/odds/wnba proxy yet.
|
wnba: ['/api/odds/wnba'],
|
||||||
mlb: null, // No /api/odds/mlb proxy yet.
|
mlb: ['/api/odds/mlb'],
|
||||||
soccer: ['/api/odds/soccer/wc'],
|
soccer: ['/api/odds/soccer/wc'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,8 +353,33 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary, #6B6B7B)' }}>
|
// Session 14 — shimmer skeletons replace the bare "Loading…" text.
|
||||||
Loading the slate…
|
// Three placeholder cards approximating GameCard dimensions; the
|
||||||
|
// shimmer animation lives in globals.css (`@keyframes
|
||||||
|
// vyndr-shimmer`) so multiple loading surfaces stay in sync.
|
||||||
|
<div style={{ display: 'grid', gap: 16 }} role="status" aria-label="Loading the slate">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-2, #12121A)',
|
||||||
|
border: '1px solid var(--border, #1A1A24)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div style={skeletonStyle({ widthPct: 60, height: 18 })} />
|
||||||
|
<div style={skeletonStyle({ widthPct: 40, height: 10 })} />
|
||||||
|
<div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
|
||||||
|
<div style={skeletonStyle({ widthPct: 88, height: 14 })} />
|
||||||
|
<div style={skeletonStyle({ widthPct: 70, height: 14 })} />
|
||||||
|
<div style={skeletonStyle({ widthPct: 80, height: 14 })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user