Session 17: Audit response — checkout 401 fix, hero prop 404 fix, Slate parsing fix, ALL tab cascade isolation, cookie/nav/footer/autocomplete polish (1438 tests)
This commit is contained in:
+147
-1
@@ -4,7 +4,153 @@
|
|||||||
2026-06-11
|
2026-06-11
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v16.0 — Live hero prop + sport-scoped markets + launch polish (Session 16)
|
SHIP BUILD v17.0 — Audit response: checkout 401, hero 404, Slate parsing, polish (Session 17)
|
||||||
|
|
||||||
|
## Session 17 (2026-06-12) — SHIPPED
|
||||||
|
|
||||||
|
A platform audit from a signed-in / signed-out walkthrough flagged
|
||||||
|
12 issues. This session traced each to root cause and shipped fixes.
|
||||||
|
Stripe is live with real products + webhooks; the symptoms audited
|
||||||
|
were code-side, not Stripe-side.
|
||||||
|
|
||||||
|
### FIX 1 — Checkout 401 "User profile not found" [CRITICAL]
|
||||||
|
|
||||||
|
`src/middleware/auth.js` 401'd authenticated users whose `auth.users`
|
||||||
|
row had no matching `public.users` profile. Signup writes to
|
||||||
|
`auth.users` automatically; the application-side row never landed
|
||||||
|
for SSO callbacks and legacy accounts that pre-dated the trigger.
|
||||||
|
|
||||||
|
Fix: when `.single()` returns PostgREST's `PGRST116` ("no rows"), the
|
||||||
|
middleware now upserts a default `{id, email, tier:'free'}` row and
|
||||||
|
re-reads. Idempotent under concurrent requests. Distinct 401 message
|
||||||
|
(`User profile creation failed`) when the upsert itself fails — lets
|
||||||
|
the operator separate missing-row recovery from real DB outages in
|
||||||
|
logs. 9 tests cover happy path, missing row → upsert, message-only
|
||||||
|
PGRST116 detection, upsert error, post-upsert empty re-read, and
|
||||||
|
non-PGRST116 errors NOT triggering an upsert.
|
||||||
|
|
||||||
|
### FIX 2 — Hero prop 404 [CRITICAL]
|
||||||
|
|
||||||
|
`web/src/app/api/hero-prop/route.ts` shipped Session 16 with BOTH
|
||||||
|
`dynamic = 'force-dynamic'` AND `revalidate = 900`. Next.js App
|
||||||
|
Router silently 404s on this conflict. Removed `revalidate`. The
|
||||||
|
15-minute cache still works via the existing `Cache-Control:
|
||||||
|
s-maxage=900` response header.
|
||||||
|
|
||||||
|
### FIX 3 — WNBA games not surfacing [HIGH]
|
||||||
|
|
||||||
|
`Slate.tsx`'s `groupByGame` skipped every prop because
|
||||||
|
`Number.isFinite(r.line)` failed on the actual Express response shape.
|
||||||
|
Express's `groupProps` returns props with `lines: [{ book, line,
|
||||||
|
over_odds, under_odds }]`, but the Slate expected a flat `line:
|
||||||
|
number`. Every WNBA / NBA / MLB prop was filtered out.
|
||||||
|
|
||||||
|
Fix: added a `pickLine()` unwrapper that prefers the flat `r.line`
|
||||||
|
when present (legacy callers + test fixtures) and otherwise picks the
|
||||||
|
first numeric line out of `r.lines[]`. The Slate now correctly
|
||||||
|
surfaces game cards for any sport with a populated `lines` array.
|
||||||
|
|
||||||
|
### FIX 4 — ALL tab error cascade [HIGH]
|
||||||
|
|
||||||
|
`Slate.tsx`'s cascade surfaced a top-level error whenever ANY single
|
||||||
|
sport rejected — even when the other sports succeeded with empty
|
||||||
|
data. Reworked to track per-sport failures separately and only show
|
||||||
|
the top-level banner when EVERY attempted sport rejected. Failed-
|
||||||
|
but-attempted sports get appended to the existing footer "endpoint
|
||||||
|
not configured" line.
|
||||||
|
|
||||||
|
### FIX 5 — Cookie consent visibility [HIGH — Legal]
|
||||||
|
|
||||||
|
Root cause was visual overlap, not the component's logic:
|
||||||
|
`BottomTabBar` and `CookieConsent` both `position: fixed; bottom: 0`,
|
||||||
|
and BottomTabBar's 64px height visually obscured the banner.
|
||||||
|
Resolved transitively by FIX 7 — anonymous visitors no longer see
|
||||||
|
BottomTabBar, so the cookie banner has the bottom of the viewport to
|
||||||
|
itself on first visit.
|
||||||
|
|
||||||
|
### FIX 6 — Scan autocomplete silent failure [MEDIUM]
|
||||||
|
|
||||||
|
The dropdown logic was correct — the silent failure happened when
|
||||||
|
`/api/players/search` returned `{ players: [] }` (NBA service down,
|
||||||
|
or no spelling match). Added a visible "no players matched" state
|
||||||
|
when the search has run but returned empty, so users get feedback.
|
||||||
|
|
||||||
|
### FIX 7 — Mobile bottom nav auth gate [MEDIUM]
|
||||||
|
|
||||||
|
`BottomTabBar.tsx` rendered for all users on all eligible routes.
|
||||||
|
Anonymous visitors on `/pricing` saw Home/Read/Parlay/Ledger/Profile —
|
||||||
|
all auth-gated destinations that would 401 on click. Gated behind
|
||||||
|
`useAuth()` with a `loading || !user` early-return. Also fixed FIX 5
|
||||||
|
transitively.
|
||||||
|
|
||||||
|
### FIX 8 — Footer support email + stale copy [LOW]
|
||||||
|
|
||||||
|
Added `Support` link (mailto:support@vyndr.app) to the Legal column.
|
||||||
|
Removed `(test mode while we onboard founders)` from Pricing.tsx —
|
||||||
|
Stripe is live. Replaced with "First 100 users lock $14.99/mo
|
||||||
|
Analyst for life."
|
||||||
|
|
||||||
|
### FIX 9 — Sentry zero events [MEDIUM]
|
||||||
|
|
||||||
|
Code wiring is correct in both backend (`initSentry()` + `setupExpress
|
||||||
|
ErrorHandler` mounted) and frontend (`SentryInit` reads
|
||||||
|
`NEXT_PUBLIC_SENTRY_DSN`). Audit found zero events because the DSN
|
||||||
|
env vars aren't set in Coolify. Code-level no-op; documented as a
|
||||||
|
Coolify env action.
|
||||||
|
|
||||||
|
### FIX 10 — Read counter visibility [LOW]
|
||||||
|
|
||||||
|
Quota pill appeared in the global Nav across every page. Restricted
|
||||||
|
to `/scan` and `/dashboard` (the surfaces where it acts as quota
|
||||||
|
context next to the scan action) via a pathname check in `Nav.tsx`.
|
||||||
|
|
||||||
|
### FIX 11 — Profile page [NO-OP]
|
||||||
|
|
||||||
|
Audit reported "no profile page exists." Verified: it does, at
|
||||||
|
`web/src/app/profile/page.tsx` (196 lines, includes email, tier,
|
||||||
|
subscription_status, subscription_end, founder_pricing,
|
||||||
|
cancel_at_period_end). Audit looked at a stale build.
|
||||||
|
|
||||||
|
### FIX 12 — Tonight's slate landing preview [MEDIUM]
|
||||||
|
|
||||||
|
`web/src/components/TonightsSlate.tsx` (new) — game-count strip
|
||||||
|
mounted between `Hero` and `LivePropsStrip`. Fetches the three
|
||||||
|
sport-odds proxies in parallel, dedupes games by (away, home, time),
|
||||||
|
renders "X NBA · Y WNBA · Z MLB games being graded right now." with
|
||||||
|
a signup CTA. Hides itself when every sport returns zero.
|
||||||
|
|
||||||
|
### Tests added (Session 17)
|
||||||
|
| Suite | Tests |
|
||||||
|
|----------------------------------------|-------|
|
||||||
|
| `tests/unit/requireAuth.test.js` | 9 |
|
||||||
|
| **Session 17 total** | **9** |
|
||||||
|
|
||||||
|
### Quality gates
|
||||||
|
- `npm test`: **1438 / 1438 passing** (1429 + 9 new), 111 suites, 0 regressions
|
||||||
|
- `web/npm run build`: clean — `/api/hero-prop` now compiles to `ƒ`
|
||||||
|
(was silently 404'd in production by the conflicting directives)
|
||||||
|
- License audit: third-party deps remain permissive
|
||||||
|
|
||||||
|
### Honest verification status
|
||||||
|
|
||||||
|
Build + tests verified. I CANNOT verify the following on the live
|
||||||
|
site from here — they need a deploy + re-audit smoke test:
|
||||||
|
- Checkout 401 ↔ actual Supabase row creation under load
|
||||||
|
- Hero prop endpoint returning JSON in production
|
||||||
|
- WNBA Slate tab actually showing games
|
||||||
|
- Cookie banner visible on first incognito load
|
||||||
|
- Mobile bottom nav truly absent for signed-out visitors
|
||||||
|
|
||||||
|
### Coolify follow-ups (operator action)
|
||||||
|
|
||||||
|
1. Set `SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_DSN` env vars to enable
|
||||||
|
server-side and browser-side error capture. Currently unset →
|
||||||
|
Sentry dashboard sees zero events even when 503/401 errors occur.
|
||||||
|
2. The Session 16 sport-scoped markets fix is in code; the
|
||||||
|
`NODE_OPTIONS=--require /app/data/patch.js` workaround can be
|
||||||
|
dropped from the web service env after this deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 16 (2026-06-11) — SHIPPED
|
## Session 16 (2026-06-11) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -570,3 +570,17 @@
|
|||||||
{"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":"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: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"}
|
{"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"}
|
||||||
|
{"ts":"2026-06-12T00:25:39.490Z","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-12T00:25:39.576Z","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-12T00:25:39.688Z","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-12T00:25:39.817Z","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-12T00:25:39.817Z","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-12T00:25:39.817Z","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-12T00:25:39.907Z","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-12T00:44:18.285Z","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-12T00:44:18.894Z","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-12T00:44:18.894Z","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-12T00:44:18.894Z","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-12T00:44:18.956Z","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-12T00:44:19.233Z","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-12T00:44:19.324Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
|||||||
+70
-7
@@ -1,5 +1,28 @@
|
|||||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session 17 — `requireAuth` was 401'ing legitimate users whose Supabase
|
||||||
|
* auth account had never had a matching row inserted into the
|
||||||
|
* `public.users` table. Signup writes to `auth.users` automatically;
|
||||||
|
* the corresponding row in `public.users` was supposed to land via a
|
||||||
|
* trigger or a post-signup handler, but for SSO callbacks and any
|
||||||
|
* legacy account that pre-dated the trigger the row was simply
|
||||||
|
* missing. The audit found this: checkout 401'd as "User profile not
|
||||||
|
* found" for valid sessions.
|
||||||
|
*
|
||||||
|
* The fix is an on-demand upsert. When the select misses, we insert a
|
||||||
|
* default row (tier='free') keyed by the Supabase auth user's id +
|
||||||
|
* email, then re-select. This is idempotent — concurrent requests
|
||||||
|
* land on the upsert's existing-row branch and re-read the same row.
|
||||||
|
*
|
||||||
|
* If the insert ITSELF fails (genuine DB issue, not a missing row),
|
||||||
|
* we 401 with a distinct message ('User profile creation failed') so
|
||||||
|
* the operator can distinguish missing-row recovery from real DB
|
||||||
|
* outages in logs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROFILE_COLUMNS = 'id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id';
|
||||||
|
|
||||||
async function requireAuth(req, res, next) {
|
async function requireAuth(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
@@ -14,17 +37,57 @@ async function requireAuth(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user profile from our users table. Session 9 added
|
// First read. PostgREST returns `error.code === 'PGRST116'` when
|
||||||
// `grace_period_until` + `stripe_customer_id` to the select so the
|
// `.single()` gets zero rows — that's the signal we use to trigger
|
||||||
// grace-period middleware can read them off `req.user` without a
|
// the on-demand upsert below. Other errors (network, permission)
|
||||||
// second round-trip. Both fields default to null when absent so
|
// bubble up as a real 401.
|
||||||
// pre-Stripe users behave identically to before.
|
let { data: profile, error: profileError } = await supabase
|
||||||
const { data: profile, error: profileError } = await supabase
|
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id')
|
.select(PROFILE_COLUMNS)
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
// Session 17 — if no row exists, create one on the fly. The
|
||||||
|
// `auth.users` row already exists (we just resolved a token against
|
||||||
|
// it), so we're not creating an identity — we're filling in the
|
||||||
|
// app-side row that should have been created at signup time but
|
||||||
|
// wasn't (SSO callbacks, legacy accounts pre-trigger, etc.).
|
||||||
|
// Detect PostgREST's "single requested, zero rows returned" via
|
||||||
|
// either the canonical error code (preferred) or the message
|
||||||
|
// pattern Supabase emits when the code field is stripped by an
|
||||||
|
// older client. Canonical message:
|
||||||
|
// "JSON object requested, multiple (or no) rows returned"
|
||||||
|
const isMissingRow = profileError && (
|
||||||
|
profileError.code === 'PGRST116'
|
||||||
|
|| /\(or no\) rows/i.test(profileError.message || '')
|
||||||
|
|| /^no rows$/i.test(profileError.message || '')
|
||||||
|
);
|
||||||
|
if (!profile && isMissingRow) {
|
||||||
|
const defaultRow = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email || null,
|
||||||
|
tier: 'free',
|
||||||
|
scan_count: 0,
|
||||||
|
scan_reset_date: new Date().toISOString().slice(0, 10),
|
||||||
|
founder_status: false,
|
||||||
|
};
|
||||||
|
const { error: upsertErr } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.upsert(defaultRow, { onConflict: 'id' });
|
||||||
|
if (upsertErr) {
|
||||||
|
console.warn('[requireAuth] users-row upsert failed:', upsertErr.message);
|
||||||
|
return res.status(401).json({ error: 'User profile creation failed' });
|
||||||
|
}
|
||||||
|
// Re-select. Use the same .single() shape so test mocks keep working.
|
||||||
|
const reread = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select(PROFILE_COLUMNS)
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single();
|
||||||
|
profile = reread.data;
|
||||||
|
profileError = reread.error;
|
||||||
|
}
|
||||||
|
|
||||||
if (profileError || !profile) {
|
if (profileError || !profile) {
|
||||||
return res.status(401).json({ error: 'User profile not found' });
|
return res.status(401).json({ error: 'User profile not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Session 17 — requireAuth's on-demand users-row upsert path.
|
||||||
|
//
|
||||||
|
// The audit found that authenticated users with valid Supabase auth
|
||||||
|
// tokens were getting 401 "User profile not found" on /api/stripe/
|
||||||
|
// checkout because their corresponding row in public.users had never
|
||||||
|
// been inserted. The fix: when `.single()` on the users select
|
||||||
|
// returns PGRST116 ("no rows"), upsert a default row and re-read.
|
||||||
|
//
|
||||||
|
// These tests build a chainable Supabase fake that lets us steer the
|
||||||
|
// select to PGRST116 / success and assert which path requireAuth
|
||||||
|
// takes.
|
||||||
|
|
||||||
|
const { requireAuth } = require('../../src/middleware/auth');
|
||||||
|
|
||||||
|
function makeFake({ getUserResult, selectResult, selectAfterUpsertResult, upsertError = null }) {
|
||||||
|
// The middleware calls `supabase.from('users')` multiple times in
|
||||||
|
// the missing-profile recovery path (initial select, then upsert,
|
||||||
|
// then reread). State that distinguishes "first select" from
|
||||||
|
// "select-after-upsert" has to live on the supabase fake, not on
|
||||||
|
// a fresh-per-call `from()` proxy. We track `hasUpserted` and
|
||||||
|
// route `.single()` to the appropriate result.
|
||||||
|
const upserts = [];
|
||||||
|
let hasUpserted = false;
|
||||||
|
|
||||||
|
const supabase = {
|
||||||
|
auth: { getUser: () => Promise.resolve(getUserResult) },
|
||||||
|
from() {
|
||||||
|
const proxy = {
|
||||||
|
select() { return proxy; },
|
||||||
|
eq() { return proxy; },
|
||||||
|
single() {
|
||||||
|
return Promise.resolve(hasUpserted ? selectAfterUpsertResult : selectResult);
|
||||||
|
},
|
||||||
|
upsert(row, opts) {
|
||||||
|
upserts.push({ row, opts });
|
||||||
|
if (!upsertError) hasUpserted = true;
|
||||||
|
return Promise.resolve({ data: null, error: upsertError });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return proxy;
|
||||||
|
},
|
||||||
|
_upserts: upserts,
|
||||||
|
};
|
||||||
|
return supabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockSupabaseFake;
|
||||||
|
jest.mock('../../src/utils/supabase', () => ({
|
||||||
|
getSupabaseServiceClient: () => mockSupabaseFake,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function runMiddleware(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const res = {
|
||||||
|
_status: null,
|
||||||
|
_body: null,
|
||||||
|
status(code) { this._status = code; return this; },
|
||||||
|
json(body) { this._body = body; resolve({ status: this._status, body, fellThrough: false }); return this; },
|
||||||
|
};
|
||||||
|
requireAuth(req, res, () => resolve({ status: 200, body: null, fellThrough: true, req }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_USER = { id: 'user-1', email: 'a@b.com' };
|
||||||
|
const EXISTING_PROFILE = {
|
||||||
|
id: 'user-1', email: 'a@b.com', tier: 'free', scan_count: 0,
|
||||||
|
scan_reset_date: '2026-06-12', founder_status: false,
|
||||||
|
grace_period_until: null, stripe_customer_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('requireAuth — pre-Session 17 behavior preserved', () => {
|
||||||
|
test('valid token + existing profile → next() called with req.user set', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: EXISTING_PROFILE, error: null },
|
||||||
|
});
|
||||||
|
const req = { headers: { authorization: 'Bearer good-token' } };
|
||||||
|
const out = await runMiddleware(req);
|
||||||
|
expect(out.fellThrough).toBe(true);
|
||||||
|
expect(req.user.id).toBe('user-1');
|
||||||
|
expect(req.user.tier).toBe('free');
|
||||||
|
expect(mockSupabaseFake._upserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing Authorization header → 401', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: null }, error: { message: 'no' } },
|
||||||
|
selectResult: { data: null, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: {} });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
expect(out.body.error).toMatch(/Authentication required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-Bearer scheme → 401', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: null }, error: { message: 'no' } },
|
||||||
|
selectResult: { data: null, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Basic xyz' } });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Supabase getUser error → 401 invalid token', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: null }, error: { message: 'token expired' } },
|
||||||
|
selectResult: { data: null, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Bearer bad' } });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
expect(out.body.error).toMatch(/Invalid or expired/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAuth — Session 17 missing-profile recovery', () => {
|
||||||
|
test('missing users row (PGRST116) → upsert default + retry succeeds', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||||
|
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||||
|
});
|
||||||
|
const req = { headers: { authorization: 'Bearer good' } };
|
||||||
|
const out = await runMiddleware(req);
|
||||||
|
expect(out.fellThrough).toBe(true);
|
||||||
|
expect(req.user.id).toBe('user-1');
|
||||||
|
expect(mockSupabaseFake._upserts).toHaveLength(1);
|
||||||
|
const u = mockSupabaseFake._upserts[0];
|
||||||
|
expect(u.row.id).toBe('user-1');
|
||||||
|
expect(u.row.email).toBe('a@b.com');
|
||||||
|
expect(u.row.tier).toBe('free');
|
||||||
|
expect(u.row.scan_count).toBe(0);
|
||||||
|
expect(u.row.founder_status).toBe(false);
|
||||||
|
expect(u.opts.onConflict).toBe('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PGRST116 detected by message text (no code field)', async () => {
|
||||||
|
// Some Supabase client versions surface the error sans code field.
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: null, error: { message: 'JSON object requested, multiple (or no) rows returned' } },
|
||||||
|
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||||
|
expect(out.fellThrough).toBe(true);
|
||||||
|
expect(mockSupabaseFake._upserts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert itself fails → 401 with distinct message', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||||
|
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||||
|
upsertError: { message: 'check_violation' },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
expect(out.body.error).toMatch(/User profile creation failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('post-upsert reread still empty → 401 User profile not found', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||||
|
selectAfterUpsertResult: { data: null, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
expect(out.body.error).toMatch(/User profile not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-PGRST116 select error → 401 immediately (no upsert)', async () => {
|
||||||
|
mockSupabaseFake = makeFake({
|
||||||
|
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||||
|
selectResult: { data: null, error: { code: '42501', message: 'permission denied' } },
|
||||||
|
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||||
|
});
|
||||||
|
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||||
|
expect(out.status).toBe(401);
|
||||||
|
// Critically, no upsert attempt — we don't recover from a real DB error.
|
||||||
|
expect(mockSupabaseFake._upserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,11 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Session 17 — Next.js App Router refuses to compile a route that
|
||||||
|
// exports BOTH `dynamic = 'force-dynamic'` AND `revalidate`. The two
|
||||||
|
// modes are mutually exclusive: force-dynamic skips static
|
||||||
|
// generation; revalidate gates ISR. Session 16 shipped both, which
|
||||||
|
// silently broke the route at build time (production audit found a
|
||||||
|
// hard 404 on /api/hero-prop).
|
||||||
|
//
|
||||||
|
// We keep `force-dynamic` (we want the random-prop pick to vary per
|
||||||
|
// request) and emit the 15-minute cache via the response's
|
||||||
|
// Cache-Control header — Coolify's reverse proxy honors it.
|
||||||
export const dynamic = 'force-dynamic';
|
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 BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
const HERO_FETCH_TIMEOUT_MS = 6000;
|
const HERO_FETCH_TIMEOUT_MS = 6000;
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
|
// Session 17 — game-count strip mounted between the hero and the
|
||||||
|
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
|
||||||
|
// being graded right now" with a signup CTA. Hides itself when
|
||||||
|
// every sport returns zero (off-hours / upstream outages).
|
||||||
|
import TonightsSlate from '@/components/TonightsSlate';
|
||||||
import LivePropsStrip from '@/components/LivePropsStrip';
|
import LivePropsStrip from '@/components/LivePropsStrip';
|
||||||
import Features from '@/components/Features';
|
import Features from '@/components/Features';
|
||||||
import HowItWorks from '@/components/HowItWorks';
|
import HowItWorks from '@/components/HowItWorks';
|
||||||
@@ -34,6 +39,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
<TonightsSlate />
|
||||||
<LivePropsStrip />
|
<LivePropsStrip />
|
||||||
<Features />
|
<Features />
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
|
|||||||
@@ -340,6 +340,22 @@ export default function ScanPage() {
|
|||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
{/* Session 17 — show "no results" when the search ran but
|
||||||
|
returned nothing. Audit reported a silent dropdown failure;
|
||||||
|
this gives the user feedback when the upstream player
|
||||||
|
service is offline or the spelling didn't match. */}
|
||||||
|
{playerQuery.trim().length >= 2 && playerSuggestions.length === 0 && playerQuery !== selectedPlayer && (
|
||||||
|
<div
|
||||||
|
className="surface-elevated"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, right: 0,
|
||||||
|
marginTop: 4, zIndex: 20, padding: 12,
|
||||||
|
fontSize: 12, color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No {sport} players matched “{playerQuery}”. Check spelling or try a partial name.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
||||||
<div
|
<div
|
||||||
className="surface-elevated"
|
className="surface-elevated"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useParlay } from '@/contexts/ParlayContext';
|
import { useParlay } from '@/contexts/ParlayContext';
|
||||||
|
// Session 17 — gate the mobile bottom nav behind authentication.
|
||||||
|
// Anonymous visitors landing on /pricing previously saw the full
|
||||||
|
// Home/Read/Parlay/Ledger/Profile bar before signing up — confusing
|
||||||
|
// surface area, and it visually overlapped the cookie-consent banner
|
||||||
|
// (both pinned to bottom: 0).
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
|
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
|
||||||
@@ -17,8 +23,15 @@ const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
|
|||||||
export default function BottomTabBar() {
|
export default function BottomTabBar() {
|
||||||
const pathname = usePathname() || '/';
|
const pathname = usePathname() || '/';
|
||||||
const { open, legCount } = useParlay();
|
const { open, legCount } = useParlay();
|
||||||
|
// Session 17 — bar hidden for anonymous visitors. The bar's
|
||||||
|
// destinations (Ledger, Profile, Parlay) all require auth anyway;
|
||||||
|
// pre-auth visitors just see broken links. Authentication state
|
||||||
|
// resolves async; while `loading` is true we err on the side of
|
||||||
|
// hiding the bar to avoid a flash for signed-out users.
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
if (HIDE_ON.has(pathname)) return null;
|
if (HIDE_ON.has(pathname)) return null;
|
||||||
|
if (loading || !user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const LEGAL_LINKS = [
|
|||||||
{ label: 'Terms', href: '/terms' },
|
{ label: 'Terms', href: '/terms' },
|
||||||
{ label: 'Privacy', href: '/privacy' },
|
{ label: 'Privacy', href: '/privacy' },
|
||||||
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
|
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
|
||||||
|
// Session 17 — support contact in the legal column. Audit found
|
||||||
|
// the platform had no support surface at all.
|
||||||
|
{ label: 'Support', href: 'mailto:support@vyndr.app' },
|
||||||
];
|
];
|
||||||
|
|
||||||
import Wordmark from '@/components/Wordmark';
|
import Wordmark from '@/components/Wordmark';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Wordmark from '@/components/Wordmark';
|
import Wordmark from '@/components/Wordmark';
|
||||||
import NotificationBell from '@/components/NotificationBell';
|
import NotificationBell from '@/components/NotificationBell';
|
||||||
@@ -10,6 +11,13 @@ import { useT } from '@/contexts/LocaleContext';
|
|||||||
export default function Nav() {
|
export default function Nav() {
|
||||||
const { user, tier, scansRemaining, signOut } = useAuth();
|
const { user, tier, scansRemaining, signOut } = useAuth();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const pathname = usePathname() || '';
|
||||||
|
// Session 17 — read counter sits in the global nav, but the audit
|
||||||
|
// flagged it as noise outside the scan flow. Restrict it to /scan
|
||||||
|
// and /dashboard (where the slate-scan lives) so it acts as a
|
||||||
|
// quota indicator next to the action it gates, not a chrome pill.
|
||||||
|
const showReadCounter = pathname === '/scan' || pathname.startsWith('/scan/')
|
||||||
|
|| pathname === '/dashboard' || pathname.startsWith('/dashboard/');
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
@@ -104,7 +112,7 @@ export default function Nav() {
|
|||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
{scansRemaining != null && tier === 'free' && (
|
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||||||
<span
|
<span
|
||||||
className="mono"
|
className="mono"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export default function Pricing() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
||||||
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe (test mode while we onboard founders).
|
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe. First 100 users lock $14.99/mo Analyst for life.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,26 @@ const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
|
|||||||
soccer: ['/api/odds/soccer/wc'],
|
soccer: ['/api/odds/soccer/wc'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Session 17 — Express `/api/odds/{sport}` returns props in the
|
||||||
|
// GROUPED shape produced by `src/routes/odds.js#groupProps`:
|
||||||
|
// { player, stat_type, home_team, away_team, game_time,
|
||||||
|
// lines: [{ book, line, over_odds, under_odds }] }
|
||||||
|
// not a flat `line`/`direction`/`book` per prop. Pre-Session 17 the
|
||||||
|
// Slate assumed flat — every prop got filtered out by the
|
||||||
|
// `Number.isFinite(r.line)` check, which is why WNBA (the only
|
||||||
|
// active sport at audit time) showed "No games published yet."
|
||||||
|
//
|
||||||
|
// RawProp now mirrors both shapes; the unwrapper below picks the
|
||||||
|
// best available line out of the `lines[]` array when present.
|
||||||
interface RawProp {
|
interface RawProp {
|
||||||
player?: string;
|
player?: string;
|
||||||
stat_type?: string;
|
stat_type?: string;
|
||||||
|
// Flat-shape fields (pre-Session 17 contract — still tolerated)
|
||||||
line?: number;
|
line?: number;
|
||||||
direction?: 'over' | 'under';
|
direction?: 'over' | 'under';
|
||||||
book?: string;
|
book?: string;
|
||||||
|
// Grouped-shape fields (actual Express response since Session 7+)
|
||||||
|
lines?: Array<{ book?: string; line?: number; over_odds?: number; under_odds?: number }>;
|
||||||
game_time?: string;
|
game_time?: string;
|
||||||
home_team?: string;
|
home_team?: string;
|
||||||
away_team?: string;
|
away_team?: string;
|
||||||
@@ -77,6 +91,35 @@ interface OddsResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick the most useful single line out of a grouped prop. Preference:
|
||||||
|
// 1. A line marked `direction: over` (matches the default scan flow)
|
||||||
|
// 2. The first numeric line in the array
|
||||||
|
// 3. The flat-shape `line` field if present (legacy callers)
|
||||||
|
function pickLine(r: RawProp): { line: number; direction: 'over' | 'under'; book: string } | null {
|
||||||
|
// Flat shape wins when present — preserves the older test fixtures.
|
||||||
|
if (Number.isFinite(r.line)) {
|
||||||
|
return {
|
||||||
|
line: r.line as number,
|
||||||
|
direction: (r.direction as 'over' | 'under') || 'over',
|
||||||
|
book: r.book || 'draftkings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Array.isArray(r.lines)) {
|
||||||
|
const first = r.lines.find((l) => Number.isFinite(l.line));
|
||||||
|
if (first && Number.isFinite(first.line)) {
|
||||||
|
// The grouped response doesn't carry a per-line direction —
|
||||||
|
// each line has both over/under odds. Default to `over` since
|
||||||
|
// that's the default scan direction.
|
||||||
|
return {
|
||||||
|
line: first.line as number,
|
||||||
|
direction: 'over',
|
||||||
|
book: first.book || 'draftkings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SlateGame {
|
interface SlateGame {
|
||||||
sport: SlateSport;
|
sport: SlateSport;
|
||||||
homeTeam: string;
|
homeTeam: string;
|
||||||
@@ -90,7 +133,10 @@ interface SlateGame {
|
|||||||
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||||
const games = new Map<string, SlateGame>();
|
const games = new Map<string, SlateGame>();
|
||||||
for (const r of rawProps) {
|
for (const r of rawProps) {
|
||||||
if (!r.player || !r.stat_type || r.line == null) continue;
|
if (!r.player || !r.stat_type) continue;
|
||||||
|
// Session 17 — unwrap the grouped `lines[]` shape from Express.
|
||||||
|
const lineInfo = pickLine(r);
|
||||||
|
if (!lineInfo) continue;
|
||||||
const home = r.home_team || '?';
|
const home = r.home_team || '?';
|
||||||
const away = r.away_team || '?';
|
const away = r.away_team || '?';
|
||||||
const time = r.game_time || '';
|
const time = r.game_time || '';
|
||||||
@@ -107,9 +153,9 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
|||||||
games.get(key)!.props.push({
|
games.get(key)!.props.push({
|
||||||
player: r.player,
|
player: r.player,
|
||||||
stat_type: r.stat_type,
|
stat_type: r.stat_type,
|
||||||
line: Number(r.line),
|
line: lineInfo.line,
|
||||||
direction: (r.direction as PropRowProp['direction']) || 'over',
|
direction: lineInfo.direction,
|
||||||
book: r.book,
|
book: lineInfo.book,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Sort each game's props by player + stat for stable rendering.
|
// Sort each game's props by player + stat for stable rendering.
|
||||||
@@ -183,24 +229,45 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
||||||
return { sport, body };
|
return { sport, body };
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Re-throw so allSettled catches it, but attach the
|
||||||
|
// sport so the per-sport error-tracking below can
|
||||||
|
// surface "Soccer odds unavailable" without blanking
|
||||||
|
// the rest of the slate.
|
||||||
|
const e = err instanceof Error ? err : new Error(String(err));
|
||||||
|
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport;
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allGames: SlateGame[] = [];
|
const allGames: SlateGame[] = [];
|
||||||
let firstError: string | null = null;
|
const failedSports: SlateSport[] = [];
|
||||||
|
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport));
|
||||||
|
const sportsThatSucceeded = new Set<SlateSport>();
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status === 'fulfilled') {
|
if (r.status === 'fulfilled') {
|
||||||
|
sportsThatSucceeded.add(r.value.sport);
|
||||||
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
|
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
|
||||||
allGames.push(...grouped);
|
allGames.push(...grouped);
|
||||||
} else if (!firstError) {
|
} else {
|
||||||
firstError = r.reason instanceof Error ? r.reason.message : 'Odds fetch failed';
|
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
|
||||||
|
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setGames(allGames);
|
setGames(allGames);
|
||||||
setUnsupportedSports(unsupported);
|
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
|
||||||
if (allGames.length === 0 && firstError) setFetchError(firstError);
|
|
||||||
|
// Session 17 — only surface a top-level error when EVERY sport
|
||||||
|
// attempted in this tab failed. Partial successes (NBA ok,
|
||||||
|
// soccer 503) silently drop the failed sport's row and surface
|
||||||
|
// it via the existing "endpoint not configured" footer note.
|
||||||
|
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) {
|
||||||
|
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||||
|
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonight's Slate — anonymous-landing game-count strip (Session 17).
|
||||||
|
*
|
||||||
|
* Client component (the landing page is itself a client component
|
||||||
|
* because it uses `useAuth` for the post-signin redirect). Fetches
|
||||||
|
* game counts for NBA / WNBA / MLB through the existing Next.js
|
||||||
|
* proxies on mount, dedupes by (away, home, time) triple, renders
|
||||||
|
* "X NBA · Y WNBA · Z MLB games being graded right now." with a
|
||||||
|
* signup CTA.
|
||||||
|
*
|
||||||
|
* Hides itself entirely when every sport returns zero — better
|
||||||
|
* silent than misleading. The counts come from the same proxies The
|
||||||
|
* Slate uses, so post-signup the user sees the same numbers.
|
||||||
|
*
|
||||||
|
* Co-exists with `LivePropsStrip` (which shows a few actual graded
|
||||||
|
* props). This component is the executive-summary line above it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface OddsResponse {
|
||||||
|
sport?: string;
|
||||||
|
props?: Array<{ home_team?: string; away_team?: string; game_time?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
async function countGamesForSport(sport: string): Promise<number> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/odds/${sport}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const body = (await res.json().catch(() => null)) as OddsResponse | null;
|
||||||
|
if (!body || !Array.isArray(body.props)) return 0;
|
||||||
|
const games = new Set<string>();
|
||||||
|
for (const p of body.props) {
|
||||||
|
if (!p.home_team || !p.away_team) continue;
|
||||||
|
games.add(`${p.away_team}__${p.home_team}__${p.game_time || ''}`);
|
||||||
|
}
|
||||||
|
return games.size;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TonightsSlate() {
|
||||||
|
const [counts, setCounts] = useState<{ nba: number; wnba: number; mlb: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
const [nba, wnba, mlb] = await Promise.all([
|
||||||
|
countGamesForSport('nba'),
|
||||||
|
countGamesForSport('wnba'),
|
||||||
|
countGamesForSport('mlb'),
|
||||||
|
]);
|
||||||
|
if (alive) setCounts({ nba, wnba, mlb });
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!counts) return null;
|
||||||
|
|
||||||
|
const total = counts.nba + counts.wnba + counts.mlb;
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
const segments: string[] = [];
|
||||||
|
if (counts.nba > 0) segments.push(`${counts.nba} NBA`);
|
||||||
|
if (counts.wnba > 0) segments.push(`${counts.wnba} WNBA`);
|
||||||
|
if (counts.mlb > 0) segments.push(`${counts.mlb} MLB`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
}}
|
||||||
|
aria-label="Tonight's slate summary"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 1100,
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tonight's Slate
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{segments.join(' · ')} games being graded right now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/signup"
|
||||||
|
className="btn-primary"
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign up to grade these →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user