Session 49: Complete onboarding flow + name micro-fixes (2185 tests)
Name micro-fixes (close the normalization arc): - collapseInitials merges "J C Escarra" -> "JC Escarra" (display + key); both playerName.js copies. Added mickey:michael nickname. Onboarding flow (end-to-end, complete): - Storage: Supabase user_metadata.preferences (no migration). - API: src/routes/preferences.js GET/POST (requireAuth, admin getUserById/ updateUserById, partial merge + sanitize) + Next /api/preferences proxy. - Page: web onboarding/page.tsx — 3 steps (sports >=1 / books skip / bankroll presets+custom+skip) -> SIGNAL ACTIVE -> POST onboarding_complete:true -> 2s -> /dashboard. Redirects to login when unauthenticated. - Redirect: dashboard fetches /api/preferences fresh; new+incomplete users (created_at >= cutoff) -> /onboarding; never while auth loading; existing users exempt. - Personalization: Slate default tab = prefs.sports[0]; preferred books glow in the card lines grid (lib/books isPreferredBook, threaded dash->Slate->GameCard). - Settings: PREFERENCES section loads + edits + saves sports/books/limit. Backend 2156 -> 2185 tests (+29), 184 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-2
@@ -4,8 +4,39 @@
|
||||
2026-06-18
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v48.0 — Name normalization applied at EVERY layer (snapshot source,
|
||||
game-card strips, scan grid) + profile usage/rest wired. The dup-player arc closed.
|
||||
SHIP BUILD v49.0 — Complete onboarding flow (prefs API, 3-step page, redirect,
|
||||
dashboard personalization, settings editor) + name micro-fixes.
|
||||
|
||||
## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW
|
||||
|
||||
Backend 2156 → **2185 tests** (+29), 184 suites. Web build clean (exit 0).
|
||||
New routes: `/onboarding`, `/api/preferences`.
|
||||
|
||||
### Phase 1 — name micro-fixes (closes the normalization arc)
|
||||
`playerName.js` (both copies): collapse adjacent single-letter words
|
||||
("J C Escarra" → "JC Escarra", display + key) + added `mickey: 'michael'`.
|
||||
|
||||
### Phases 2–5 — onboarding, end-to-end
|
||||
- **Storage:** Supabase `user_metadata.preferences` — NO migration. Shape:
|
||||
`{ sports[], books[], weekly_limit, onboarding_complete }`.
|
||||
- **API:** `src/routes/preferences.js` GET/POST (requireAuth; service-client
|
||||
admin getUserById/updateUserById; POST is a PARTIAL merge + sanitized) +
|
||||
`/api/preferences` Next proxy (forwards the bearer).
|
||||
- **Page:** `web/src/app/onboarding/page.tsx` — 3 self-contained steps (sports
|
||||
[≥1 required] → books [skip] → weekly bankroll [presets + custom + skip]) →
|
||||
"SIGNAL ACTIVE / You're locked in" → POST `onboarding_complete:true` → 2s →
|
||||
`/dashboard`. Redirects to /login when unauthenticated.
|
||||
- **Redirect:** the dashboard fetches `/api/preferences` (fresh — avoids stale
|
||||
session metadata); if `onboarding_complete !== true` AND the user is NEW
|
||||
(`created_at >= 2026-06-19 cutoff`), → `/onboarding`. Never fires while auth is
|
||||
loading (would bounce unauthenticated users). Existing users are exempt.
|
||||
- **Personalization:** the Slate defaults its tab to `prefs.sports[0]`; preferred
|
||||
books are highlighted (green glow) in each game card's lines grid
|
||||
(`isPreferredBook` in `lib/books.js`, threaded dashboard → Slate → GameCard).
|
||||
- **Settings:** a PREFERENCES section loads (GET) + edits + saves (POST) sports,
|
||||
books, and weekly limit.
|
||||
|
||||
## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER
|
||||
|
||||
## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER
|
||||
|
||||
|
||||
@@ -512,6 +512,27 @@ snapshot, locked to the line, and read from cache.
|
||||
merge carries ab_per_game/rest_days; `FEATURE_NAMES` is meta bookkeeping only,
|
||||
NOT a whitelist that filters the vector).
|
||||
|
||||
## Onboarding Flow (Session 49 — non-obvious)
|
||||
- **Preferences live in Supabase `user_metadata.preferences`** (NO table/migration).
|
||||
`src/routes/preferences.js` GET/POST behind `requireAuth`, read/written via the
|
||||
service client's `auth.admin.getUserById`/`updateUserById`. POST is a PARTIAL
|
||||
merge (only body keys change) + sanitized (valid sports only, ≤12 books, clamped
|
||||
limit). Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`.
|
||||
- **Redirect reads FRESH from the API, not session metadata.** The dashboard
|
||||
fetches `/api/preferences` on mount and redirects new+incomplete users to
|
||||
`/onboarding`. Don't rely on `session.user.user_metadata` for this — it's stale
|
||||
after a backend write (admin API doesn't refresh the client session).
|
||||
- **Existing users are exempt** via a `created_at >= ONBOARDING_CUTOFF`
|
||||
(2026-06-19) check. The redirect effect also early-returns while `authLoading`
|
||||
(else it bounces unauthenticated users to onboarding instead of login).
|
||||
- **Dashboard personalization:** Slate `initialTab = prefs.sports[0]`; preferred
|
||||
books highlight in the card lines grid via `lib/books.js isPreferredBook`
|
||||
(matches DK/draftkings/DraftKings), threaded dashboard → Slate → vyndr/GameCard.
|
||||
- **Settings** has a PREFERENCES section (GET to load, POST to save) — the same
|
||||
sports/books/limit the onboarding collects.
|
||||
- **Name micro-fix:** `playerName.js` `collapseInitials` merges "J C" → "JC"
|
||||
(display + key) so space-separated initials dedupe.
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -123,6 +123,8 @@ app.use('/api/scan', scanRoutes);
|
||||
app.use('/api/movements', movementsRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/bets', betsRoutes);
|
||||
// Session 49 — per-user onboarding preferences (auth-gated, user_metadata).
|
||||
app.use('/api/preferences', require('./routes/preferences'));
|
||||
app.use('/api/stripe', stripeRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api/props', propsRoutes);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/preferences (Session 49) — per-user onboarding preferences.
|
||||
*
|
||||
* Stored in Supabase auth `user_metadata.preferences` (NO migration / extra
|
||||
* table). Auth-gated by the shared `requireAuth` (req.user = the app `users`
|
||||
* row; req.user.id is the auth user id). Reads/writes via the service client's
|
||||
* admin API. POST is a PARTIAL merge — only the keys present in the body change.
|
||||
*
|
||||
* Shape: { sports: string[], books: string[], weekly_limit: number|null,
|
||||
* onboarding_complete: boolean }
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const DEFAULTS = { sports: [], books: [], weekly_limit: null, onboarding_complete: false };
|
||||
const VALID_SPORTS = new Set(['mlb', 'nba', 'wnba', 'soccer']);
|
||||
|
||||
/** Keep only valid keys/values; only present keys are returned (partial). */
|
||||
function sanitizePrefs(p = {}) {
|
||||
const out = {};
|
||||
if (Array.isArray(p.sports)) {
|
||||
out.sports = [...new Set(p.sports.map((s) => String(s).toLowerCase()).filter((s) => VALID_SPORTS.has(s)))];
|
||||
}
|
||||
if (Array.isArray(p.books)) {
|
||||
out.books = [...new Set(p.books.map((b) => String(b).slice(0, 40)))].slice(0, 12);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(p, 'weekly_limit')) {
|
||||
const n = Number(p.weekly_limit);
|
||||
out.weekly_limit = p.weekly_limit === null ? null : Number.isFinite(n) ? Math.max(0, Math.min(100000, n)) : null;
|
||||
}
|
||||
if (typeof p.onboarding_complete === 'boolean') out.onboarding_complete = p.onboarding_complete;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readPrefs(sb, userId) {
|
||||
const { data, error } = await sb.auth.admin.getUserById(userId);
|
||||
if (error) throw error;
|
||||
const prefs = (data && data.user && data.user.user_metadata && data.user.user_metadata.preferences) || {};
|
||||
const meta = (data && data.user && data.user.user_metadata) || {};
|
||||
return { prefs: { ...DEFAULTS, ...prefs }, meta };
|
||||
}
|
||||
|
||||
// GET /api/preferences
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const sb = getSupabaseServiceClient();
|
||||
const { prefs } = await readPrefs(sb, req.user.id);
|
||||
return res.json(prefs);
|
||||
} catch (err) {
|
||||
console.error('[preferences/get]', err.message);
|
||||
return res.status(503).json({ error: 'Could not load preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/preferences — partial merge.
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const sb = getSupabaseServiceClient();
|
||||
const { prefs: existing, meta } = await readPrefs(sb, req.user.id);
|
||||
const merged = { ...existing, ...sanitizePrefs(req.body || {}) };
|
||||
const { error } = await sb.auth.admin.updateUserById(req.user.id, {
|
||||
user_metadata: { ...meta, preferences: merged },
|
||||
});
|
||||
if (error) throw error;
|
||||
return res.json({ ok: true, preferences: merged });
|
||||
} catch (err) {
|
||||
console.error('[preferences/post]', err.message);
|
||||
return res.status(503).json({ error: 'Could not save preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -37,15 +37,21 @@ const NICKNAMES = {
|
||||
charlie: 'charles', chuck: 'charles', rick: 'richard', dick: 'richard',
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
|
||||
};
|
||||
|
||||
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra",
|
||||
// "J C E Escarra" → "JCE Escarra" (PropLine sends space-separated initials).
|
||||
function collapseInitials(s) {
|
||||
return s.replace(/\b([A-Za-z])(?: ([A-Za-z]))+\b(?=\s|$)/g, (m) => m.replace(/ /g, ''));
|
||||
}
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
const display = collapseInitials(String(raw == null ? '' : raw)
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ') // strip parenthetical team tags "(STL)"
|
||||
.replace(/\./g, '') // strip dots: "A.J." → "AJ", "Jr." → "Jr"
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
.trim());
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||
return { display, key };
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Session 49 — /api/preferences (auth-gated, user_metadata storage).
|
||||
|
||||
const request = require('supertest');
|
||||
|
||||
const mockFrom = jest.fn();
|
||||
const mockGetUser = jest.fn();
|
||||
const mockGetUserById = jest.fn();
|
||||
const mockUpdateUserById = jest.fn();
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseClient: () => ({ auth: { getUser: mockGetUser, admin: { getUserById: mockGetUserById, updateUserById: mockUpdateUserById } }, from: mockFrom }),
|
||||
getSupabaseServiceClient: () => ({ auth: { getUser: mockGetUser, admin: { getUserById: mockGetUserById, updateUserById: mockUpdateUserById } }, from: mockFrom }),
|
||||
}));
|
||||
|
||||
const app = require('../../src/app');
|
||||
|
||||
const USER_ID = 'user-1';
|
||||
function authOk() {
|
||||
mockGetUser.mockResolvedValue({ data: { user: { id: USER_ID, email: 'a@b.com' } }, error: null });
|
||||
// requireAuth's users-row lookup
|
||||
mockFrom.mockImplementation(() => ({
|
||||
select: () => ({ eq: () => ({ single: () => Promise.resolve({ data: { id: USER_ID, email: 'a@b.com', tier: 'free' }, error: null }) }) }),
|
||||
}));
|
||||
}
|
||||
function metaWith(preferences) {
|
||||
mockGetUserById.mockResolvedValue({ data: { user: { id: USER_ID, user_metadata: preferences ? { preferences } : {} } }, error: null });
|
||||
mockUpdateUserById.mockResolvedValue({ data: { user: { id: USER_ID } }, error: null });
|
||||
}
|
||||
|
||||
beforeEach(() => { jest.clearAllMocks(); });
|
||||
|
||||
describe('GET /api/preferences', () => {
|
||||
it('requires auth (401 without token)', async () => {
|
||||
const res = await request(app).get('/api/preferences');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns defaults for a user with no saved prefs', async () => {
|
||||
authOk(); metaWith(null);
|
||||
const res = await request(app).get('/api/preferences').set('Authorization', 'Bearer t');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ sports: [], books: [], weekly_limit: null, onboarding_complete: false });
|
||||
});
|
||||
|
||||
it('returns saved preferences', async () => {
|
||||
authOk(); metaWith({ sports: ['mlb', 'nba'], books: ['draftkings'], weekly_limit: 250, onboarding_complete: true });
|
||||
const res = await request(app).get('/api/preferences').set('Authorization', 'Bearer t');
|
||||
expect(res.body.sports).toEqual(['mlb', 'nba']);
|
||||
expect(res.body.weekly_limit).toBe(250);
|
||||
expect(res.body.onboarding_complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/preferences', () => {
|
||||
it('requires auth', async () => {
|
||||
const res = await request(app).post('/api/preferences').send({ sports: ['mlb'] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('saves and returns the merged preferences (sanitized)', async () => {
|
||||
authOk(); metaWith(null);
|
||||
const res = await request(app).post('/api/preferences').set('Authorization', 'Bearer t')
|
||||
.send({ sports: ['MLB', 'nba', 'cricket'], books: ['draftkings'], weekly_limit: 250, onboarding_complete: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.preferences.sports).toEqual(['mlb', 'nba']); // lowercased, invalid dropped
|
||||
expect(res.body.preferences.onboarding_complete).toBe(true);
|
||||
expect(mockUpdateUserById).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ user_metadata: expect.objectContaining({ preferences: expect.any(Object) }) }));
|
||||
});
|
||||
|
||||
it('partial update merges with existing (does not overwrite other keys)', async () => {
|
||||
authOk(); metaWith({ sports: ['mlb'], books: ['fanduel'], weekly_limit: 100, onboarding_complete: true });
|
||||
const res = await request(app).post('/api/preferences').set('Authorization', 'Bearer t').send({ weekly_limit: 500 });
|
||||
expect(res.body.preferences.weekly_limit).toBe(500);
|
||||
expect(res.body.preferences.sports).toEqual(['mlb']); // preserved
|
||||
expect(res.body.preferences.books).toEqual(['fanduel']); // preserved
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Session 49 — Phase 1: name micro-fixes (initials collapse + mickey nickname).
|
||||
|
||||
const be = require('../../src/utils/playerName');
|
||||
const fe = require('../../web/src/lib/playerName');
|
||||
|
||||
describe('initials collapse', () => {
|
||||
it('"J C Escarra" === "JC Escarra"', () => {
|
||||
expect(be.nameKey('J C Escarra')).toBe(be.nameKey('JC Escarra'));
|
||||
expect(be.normalizeName('J C Escarra').display).toBe('JC Escarra');
|
||||
});
|
||||
it('handles 3+ initials ("J C E Smith" → "JCE Smith")', () => {
|
||||
expect(be.normalizeName('J C E Smith').display).toBe('JCE Smith');
|
||||
});
|
||||
it('does not collapse a single initial before a real word ("O Henry")', () => {
|
||||
expect(be.normalizeName('O Henry').display).toBe('O Henry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mickey nickname', () => {
|
||||
it('"Mickey Gasper" === "Michael Gasper"', () => {
|
||||
expect(be.nameKey('Mickey Gasper')).toBe(be.nameKey('Michael Gasper'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontend + backend copies agree', () => {
|
||||
it.each(['J C Escarra', 'Mickey Gasper', 'J C E Smith', 'O Henry', 'Aaron Judge'])('%s', (n) => {
|
||||
expect(fe.nameKey(n)).toBe(be.nameKey(n));
|
||||
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Session 49 — onboarding flow: page, redirect, personalization, settings, book
|
||||
// highlight. Page logic is asserted as source (the journey is structural);
|
||||
// book-matching logic runs directly.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const books = require('../../web/src/lib/books');
|
||||
|
||||
describe('isPreferredBook (book matching)', () => {
|
||||
it('matches a book across id / code / name forms', () => {
|
||||
expect(books.isPreferredBook('DK', ['draftkings'])).toBe(true);
|
||||
expect(books.isPreferredBook('DraftKings', ['draftkings'])).toBe(true);
|
||||
expect(books.isPreferredBook('draftkings', ['DK'])).toBe(true);
|
||||
expect(books.isPreferredBook('FD', ['draftkings'])).toBe(false);
|
||||
});
|
||||
it('returns false for empty/missing preferences', () => {
|
||||
expect(books.isPreferredBook('DK', [])).toBe(false);
|
||||
expect(books.isPreferredBook('DK', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Onboarding page', () => {
|
||||
const src = read('app/onboarding/page.tsx');
|
||||
it('renders 3 steps + a completion step', () => {
|
||||
expect(src).toContain('data-step="1"');
|
||||
expect(src).toContain('data-step="2"');
|
||||
expect(src).toContain('data-step="3"');
|
||||
expect(src).toContain('SIGNAL ACTIVE');
|
||||
expect(src).toContain("You're locked in");
|
||||
});
|
||||
it('step 1 requires at least one sport', () => {
|
||||
expect(src).toContain('disabled={sports.length === 0}');
|
||||
});
|
||||
it('saves preferences via POST with onboarding_complete:true → /dashboard', () => {
|
||||
expect(src).toContain("fetch('/api/preferences'");
|
||||
expect(src).toContain('onboarding_complete: true');
|
||||
expect(src).toContain("router.replace('/dashboard')");
|
||||
});
|
||||
it('redirects to login when not signed in (never to onboarding)', () => {
|
||||
expect(src).toContain("router.replace('/login?next=/onboarding')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard onboarding redirect + personalization', () => {
|
||||
const src = read('app/dashboard/page.tsx');
|
||||
it('fetches prefs and redirects new incomplete users to /onboarding', () => {
|
||||
expect(src).toContain("fetch('/api/preferences'");
|
||||
expect(src).toContain("router.replace('/onboarding')");
|
||||
expect(src).toContain('onboarding_complete !== true');
|
||||
});
|
||||
it('exempts existing users via created_at cutoff', () => {
|
||||
expect(src).toContain('ONBOARDING_CUTOFF');
|
||||
expect(src).toContain('isNewUser');
|
||||
});
|
||||
it('does not redirect while auth is loading', () => {
|
||||
expect(src).toContain('if (authLoading || !user || !session?.access_token) return;');
|
||||
});
|
||||
it('passes primary sport tab + preferred books to the Slate', () => {
|
||||
expect(src).toContain('initialTab={primaryTab}');
|
||||
expect(src).toContain('preferredBooks={prefBooks}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slate + GameCard thread preferred books', () => {
|
||||
it('Slate accepts + forwards preferredBooks', () => {
|
||||
const src = read('components/Slate.tsx');
|
||||
expect(src).toContain('preferredBooks');
|
||||
expect(src).toContain('preferredBooks={preferredBooks}');
|
||||
});
|
||||
it('GameCard highlights the preferred book row', () => {
|
||||
const src = read('components/vyndr/GameCard.tsx');
|
||||
expect(src).toContain('isPreferredBook(ln.book, preferredBooks)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings preferences section', () => {
|
||||
const src = read('app/settings/page.tsx');
|
||||
it('renders a PREFERENCES section that loads + saves prefs', () => {
|
||||
expect(src).toContain('label="PREFERENCES"');
|
||||
expect(src).toContain("fetch('/api/preferences'");
|
||||
expect(src).toContain('savePrefs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preferences Next proxy', () => {
|
||||
it('forwards GET + POST with auth', () => {
|
||||
const src = read('app/api/preferences/route.ts');
|
||||
expect(src).toContain('export async function GET');
|
||||
expect(src).toContain('export async function POST');
|
||||
expect(src).toContain('Authorization');
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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';
|
||||
|
||||
/**
|
||||
* Preferences proxy (Session 49) — forwards GET/POST /api/preferences to the
|
||||
* Express route (which reads/writes Supabase user_metadata via the admin API).
|
||||
* Forwards the Authorization bearer so requireAuth can resolve the user.
|
||||
*/
|
||||
function authHeaders(req: NextRequest): HeadersInit {
|
||||
const auth = req.headers.get('authorization');
|
||||
return { Accept: 'application/json', 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/preferences`, { method: 'GET', headers: authHeaders(req) });
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Preferences service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/preferences`, { method: 'POST', headers: authHeaders(req), body });
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Preferences service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ const SPORT_COLOR: Record<Sport, string> = {
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
@@ -83,12 +83,40 @@ export default function DashboardPage() {
|
||||
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(null);
|
||||
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
|
||||
// Session 49 — the user's primary sport tab + preferred books from prefs.
|
||||
const [primaryTab, setPrimaryTab] = useState<'all' | 'nba' | 'mlb' | 'wnba' | 'soccer'>('all');
|
||||
const [prefBooks, setPrefBooks] = useState<string[]>([]);
|
||||
|
||||
// Gate
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/dashboard');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
// Session 49 — onboarding gate + dashboard personalization. New users
|
||||
// (created after onboarding shipped) who haven't completed it are sent to
|
||||
// /onboarding; everyone else's saved preferences set the default sport tab.
|
||||
// Never fires while auth is loading (would bounce unauthenticated users).
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || !session?.access_token) return;
|
||||
let active = true;
|
||||
fetch('/api/preferences', { headers: { Authorization: `Bearer ${session.access_token}` } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((prefs) => {
|
||||
if (!active || !prefs) return;
|
||||
const createdAt = session.user?.created_at ? new Date(session.user.created_at).getTime() : 0;
|
||||
const ONBOARDING_CUTOFF = new Date('2026-06-19T00:00:00Z').getTime();
|
||||
const isNewUser = createdAt >= ONBOARDING_CUTOFF;
|
||||
if (prefs.onboarding_complete !== true && isNewUser) {
|
||||
router.replace('/onboarding');
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(prefs.sports) && prefs.sports[0]) setPrimaryTab(prefs.sports[0]);
|
||||
if (Array.isArray(prefs.books)) setPrefBooks(prefs.books);
|
||||
})
|
||||
.catch(() => { /* prefs are best-effort — never block the dashboard */ });
|
||||
return () => { active = false; };
|
||||
}, [authLoading, user, session, router]);
|
||||
|
||||
// Fetch slate when sport changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -198,7 +226,7 @@ export default function DashboardPage() {
|
||||
search, and inline grading. Renders ABOVE the existing
|
||||
intelligence sections (Top Graded / Most Parlayed / Recent
|
||||
Reads) which serve as supplementary surfaces. */}
|
||||
<Slate tier={tier} />
|
||||
<Slate tier={tier} initialTab={primaryTab} preferredBooks={prefBooks} key={primaryTab} />
|
||||
|
||||
{/* Legacy sport tabs — supplementary, kept for the existing
|
||||
Top Graded / Most Parlayed flows below. */}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Wordmark from '@/components/vyndr/Wordmark';
|
||||
|
||||
/**
|
||||
* /onboarding (Session 49) — first-run experience. 3 self-contained steps
|
||||
* (sports → books → weekly bankroll) → saves to /api/preferences with
|
||||
* onboarding_complete:true → /dashboard. Step 1 requires ≥1 sport; books +
|
||||
* limit are skippable.
|
||||
*/
|
||||
|
||||
const SPORTS = [
|
||||
{ id: 'mlb', label: 'MLB' },
|
||||
{ id: 'nba', label: 'NBA' },
|
||||
{ id: 'wnba', label: 'WNBA' },
|
||||
{ id: 'soccer', label: 'Soccer' },
|
||||
];
|
||||
const BOOKS = [
|
||||
{ id: 'draftkings', label: 'DraftKings' },
|
||||
{ id: 'fanduel', label: 'FanDuel' },
|
||||
{ id: 'betmgm', label: 'BetMGM' },
|
||||
{ id: 'caesars', label: 'Caesars' },
|
||||
{ id: 'bet365', label: 'bet365' },
|
||||
{ id: 'odawa', label: 'Odawa Online' },
|
||||
];
|
||||
const LIMITS = [50, 100, 250, 500, 1000];
|
||||
|
||||
const ACCENT = 'var(--g-a)';
|
||||
|
||||
function Chip({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mono"
|
||||
style={{
|
||||
cursor: 'pointer', padding: '12px 18px', borderRadius: 10, fontSize: 13, fontWeight: 700, letterSpacing: '0.04em',
|
||||
background: on ? 'color-mix(in srgb, var(--g-a) 14%, transparent)' : 'var(--bg-2)',
|
||||
border: `1px solid ${on ? ACCENT : 'var(--border-hi)'}`,
|
||||
color: on ? ACCENT : 'var(--text-1)', transition: '.15s',
|
||||
}}
|
||||
aria-pressed={on}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { user, session, loading } = useAuth();
|
||||
const [step, setStep] = useState(1);
|
||||
const [sports, setSports] = useState<string[]>([]);
|
||||
const [books, setBooks] = useState<string[]>([]);
|
||||
const [weeklyLimit, setWeeklyLimit] = useState<number | null>(null);
|
||||
const [customLimit, setCustomLimit] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Must be signed in (our session lives client-side).
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace('/login?next=/onboarding');
|
||||
}, [loading, user, router]);
|
||||
|
||||
const toggle = (list: string[], set: (v: string[]) => void, id: string) =>
|
||||
set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
||||
|
||||
const finish = useMemo(() => async () => {
|
||||
setSaving(true);
|
||||
const limit = weeklyLimit ?? (customLimit ? Number(customLimit) : null);
|
||||
try {
|
||||
await fetch('/api/preferences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) },
|
||||
body: JSON.stringify({ sports, books, weekly_limit: limit, onboarding_complete: true }),
|
||||
});
|
||||
} catch { /* still proceed — prefs are best-effort */ }
|
||||
setStep(4);
|
||||
setTimeout(() => router.replace('/dashboard'), 2000);
|
||||
}, [sports, books, weeklyLimit, customLimit, session, router]);
|
||||
|
||||
if (loading || !user) {
|
||||
return <section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading…</p></section>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="scanlines" style={{ maxWidth: 560, margin: '0 auto', padding: '40px 16px 120px', minHeight: '70vh' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||
<Wordmark size="md" />
|
||||
{step <= 3 && (
|
||||
<div className="mono" style={{ marginTop: 14, fontSize: 11, color: 'var(--text-2)', letterSpacing: '0.12em' }}>
|
||||
STEP {step} OF 3
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* STEP 1 — sports */}
|
||||
{step === 1 && (
|
||||
<div data-step="1">
|
||||
<h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-0.01em', marginBottom: 8 }}>What do you follow?</h1>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 22 }}>Select the sports you bet on. VYNDR will prioritize your slate.</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28 }}>
|
||||
{SPORTS.map((s) => <Chip key={s.id} label={s.label} on={sports.includes(s.id)} onClick={() => toggle(sports, setSports, s.id)} />)}
|
||||
</div>
|
||||
<button type="button" disabled={sports.length === 0} onClick={() => setStep(2)} className="mono"
|
||||
style={{ width: '100%', padding: '14px', borderRadius: 10, fontWeight: 700, letterSpacing: '0.06em', cursor: sports.length ? 'pointer' : 'not-allowed', border: 'none', background: sports.length ? ACCENT : 'var(--bg-3)', color: sports.length ? '#06060B' : 'var(--text-2)' }}>
|
||||
NEXT →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2 — books */}
|
||||
{step === 2 && (
|
||||
<div data-step="2">
|
||||
<h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-0.01em', marginBottom: 8 }}>Where do you bet?</h1>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 22 }}>Connect your book so VYNDR can show you the best lines and enable one-tap betting.</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28 }}>
|
||||
{BOOKS.map((b) => <Chip key={b.id} label={b.label} on={books.includes(b.id)} onClick={() => toggle(books, setBooks, b.id)} />)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<button type="button" onClick={() => setStep(3)} className="mono"
|
||||
style={{ flex: 1, padding: '14px', borderRadius: 10, fontWeight: 700, letterSpacing: '0.06em', cursor: 'pointer', border: 'none', background: ACCENT, color: '#06060B' }}>NEXT →</button>
|
||||
<button type="button" onClick={() => { setBooks([]); setStep(3); }} className="mono" style={{ background: 'transparent', border: 'none', color: 'var(--text-2)', cursor: 'pointer', fontSize: 12 }}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3 — weekly limit */}
|
||||
{step === 3 && (
|
||||
<div data-step="3">
|
||||
<h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-0.01em', marginBottom: 8 }}>Set your weekly bankroll</h1>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 22 }}>VYNDR helps you stay disciplined. We'll track your weekly action.</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
|
||||
{LIMITS.map((l) => <Chip key={l} label={`$${l}`} on={weeklyLimit === l} onClick={() => { setWeeklyLimit(l); setCustomLimit(''); }} />)}
|
||||
</div>
|
||||
<input
|
||||
inputMode="numeric" value={customLimit}
|
||||
onChange={(e) => { setCustomLimit(e.target.value.replace(/[^0-9]/g, '')); setWeeklyLimit(null); }}
|
||||
placeholder="Custom amount ($)"
|
||||
className="mono"
|
||||
style={{ width: '100%', padding: '12px 14px', borderRadius: 10, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', color: '#fff', fontSize: 14, marginBottom: 28, outline: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<button type="button" disabled={saving} onClick={finish} className="mono"
|
||||
style={{ flex: 1, padding: '14px', borderRadius: 10, fontWeight: 700, letterSpacing: '0.06em', cursor: 'pointer', border: 'none', background: ACCENT, color: '#06060B' }}>
|
||||
{saving ? 'SAVING…' : 'FINISH →'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setWeeklyLimit(null); setCustomLimit(''); finish(); }} className="mono" style={{ background: 'transparent', border: 'none', color: 'var(--text-2)', cursor: 'pointer', fontSize: 12 }}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* COMPLETION */}
|
||||
{step === 4 && (
|
||||
<div data-step="done" style={{ textAlign: 'center', paddingTop: 30 }}>
|
||||
<div className="mono glitch-hover amber-glow" data-text="SIGNAL ACTIVE" style={{ fontSize: 22, fontWeight: 800, color: ACCENT, letterSpacing: '0.12em', marginBottom: 14 }}>SIGNAL ACTIVE</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.01em', marginBottom: 10 }}>You're locked in.</h1>
|
||||
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>Taking you to your slate…</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
@@ -41,15 +41,70 @@ function tierLabel(tier: string) {
|
||||
return { label: 'FREE', color: 'var(--text-1)' };
|
||||
}
|
||||
|
||||
const PREF_SPORTS = [
|
||||
{ id: 'mlb', label: 'MLB' }, { id: 'nba', label: 'NBA' }, { id: 'wnba', label: 'WNBA' }, { id: 'soccer', label: 'Soccer' },
|
||||
];
|
||||
const PREF_BOOKS = [
|
||||
{ id: 'draftkings', label: 'DraftKings' }, { id: 'fanduel', label: 'FanDuel' }, { id: 'betmgm', label: 'BetMGM' },
|
||||
{ id: 'caesars', label: 'Caesars' }, { id: 'bet365', label: 'bet365' }, { id: 'odawa', label: 'Odawa Online' },
|
||||
];
|
||||
|
||||
function PrefChip({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="mono" aria-pressed={on}
|
||||
style={{ cursor: 'pointer', padding: '8px 13px', borderRadius: 8, fontSize: 12, fontWeight: 700,
|
||||
background: on ? 'color-mix(in srgb, var(--g-a) 14%, transparent)' : 'var(--bg-2)',
|
||||
border: `1px solid ${on ? 'var(--g-a)' : 'var(--border-hi)'}`, color: on ? 'var(--g-a)' : 'var(--text-1)' }}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier } = useAuth();
|
||||
const { user, tier, session } = useAuth();
|
||||
const [emailAlerts, setEmailAlerts] = useState(true);
|
||||
const [pushAlerts, setPushAlerts] = useState(false);
|
||||
const [deleteText, setDeleteText] = useState('');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [delError, setDelError] = useState('');
|
||||
|
||||
// Session 49 — onboarding preferences (load + edit + save).
|
||||
const [prefSports, setPrefSports] = useState<string[]>([]);
|
||||
const [prefBooks, setPrefBooks] = useState<string[]>([]);
|
||||
const [prefLimit, setPrefLimit] = useState<string>('');
|
||||
const [prefSaving, setPrefSaving] = useState(false);
|
||||
const [prefSaved, setPrefSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.access_token) return;
|
||||
fetch('/api/preferences', { headers: { Authorization: `Bearer ${session.access_token}` } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((p) => {
|
||||
if (!p) return;
|
||||
setPrefSports(Array.isArray(p.sports) ? p.sports : []);
|
||||
setPrefBooks(Array.isArray(p.books) ? p.books : []);
|
||||
setPrefLimit(p.weekly_limit != null ? String(p.weekly_limit) : '');
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [session]);
|
||||
|
||||
const toggle = (list: string[], set: (v: string[]) => void, id: string) =>
|
||||
set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
||||
|
||||
const savePrefs = async () => {
|
||||
setPrefSaving(true);
|
||||
setPrefSaved(false);
|
||||
try {
|
||||
const res = await fetch('/api/preferences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) },
|
||||
body: JSON.stringify({ sports: prefSports, books: prefBooks, weekly_limit: prefLimit ? Number(prefLimit) : null }),
|
||||
});
|
||||
if (res.ok) setPrefSaved(true);
|
||||
} catch { /* best-effort */ } finally { setPrefSaving(false); }
|
||||
};
|
||||
|
||||
const plan = tierLabel(tier || 'free');
|
||||
const canDelete = deleteText === 'DELETE';
|
||||
|
||||
@@ -121,6 +176,28 @@ export default function SettingsPage() {
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* PREFERENCES (Session 49) */}
|
||||
<Section label="PREFERENCES">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Sports</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{PREF_SPORTS.map((s) => <PrefChip key={s.id} label={s.label} on={prefSports.includes(s.id)} onClick={() => toggle(prefSports, setPrefSports, s.id)} />)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Books</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{PREF_BOOKS.map((b) => <PrefChip key={b.id} label={b.label} on={prefBooks.includes(b.id)} onClick={() => toggle(prefBooks, setPrefBooks, b.id)} />)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Weekly limit</div>
|
||||
<input inputMode="numeric" value={prefLimit} onChange={(e) => setPrefLimit(e.target.value.replace(/[^0-9]/g, ''))} placeholder="$ amount"
|
||||
className="mono" style={{ width: 160, padding: '9px 12px', borderRadius: 8, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', color: '#fff', fontSize: 13, outline: 'none', marginBottom: 16 }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="button" onClick={savePrefs} disabled={prefSaving} className="mono"
|
||||
style={{ cursor: 'pointer', padding: '9px 16px', borderRadius: 8, fontWeight: 700, fontSize: 11, letterSpacing: '0.04em', border: '1px solid var(--g-a)', background: 'var(--g-a)', color: '#06060B' }}>
|
||||
{prefSaving ? 'SAVING…' : 'SAVE'}
|
||||
</button>
|
||||
{prefSaved && <span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>Saved ✓</span>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* NOTIFICATIONS */}
|
||||
<Section label="NOTIFICATIONS">
|
||||
<Row>
|
||||
|
||||
@@ -346,9 +346,11 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
export interface SlateProps {
|
||||
initialTab?: SlateTab;
|
||||
tier?: Tier;
|
||||
/** Session 49 — user's preferred books (highlighted in each card's lines). */
|
||||
preferredBooks?: string[];
|
||||
}
|
||||
|
||||
export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) {
|
||||
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks }: SlateProps) {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
const [tab, setTab] = useState<SlateTab>(initialTab);
|
||||
@@ -751,6 +753,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
<VyndrGameCard
|
||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
|
||||
preferredBooks={preferredBooks}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { isPreferredBook } from '@/lib/books';
|
||||
|
||||
export interface GameLine {
|
||||
book: string;
|
||||
@@ -62,6 +63,8 @@ interface GameCardProps {
|
||||
game: GameCardData;
|
||||
onAddParlay?: (p: GameProp) => void;
|
||||
onOpen?: (id: string) => void;
|
||||
/** Session 49 — the user's preferred books, highlighted in the lines grid. */
|
||||
preferredBooks?: string[];
|
||||
}
|
||||
|
||||
/** A book-line cell with the Bloomberg pattern: best = green tint + green left
|
||||
@@ -116,7 +119,7 @@ function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: G
|
||||
|
||||
/** Dashboard / Slate game card (§7) — game-lines grid w/ best-line highlight,
|
||||
* graded props, inline streaks, live indicator. */
|
||||
export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps) {
|
||||
export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
|
||||
return (
|
||||
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* HEADER */}
|
||||
@@ -178,7 +181,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||
{g.lines.map((ln, i) => (
|
||||
<span key={i} style={{ display: 'contents' }}>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', paddingLeft: 2 }}>{ln.book}</div>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{ln.book}</div>
|
||||
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
||||
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
||||
<LineCell value={ln.ou} best={ln.bestOU} />
|
||||
|
||||
+14
-1
@@ -26,4 +26,17 @@ function bookInfo(book) {
|
||||
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
|
||||
}
|
||||
|
||||
module.exports = { BOOKS, bookInfo };
|
||||
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
|
||||
* /"DraftKings" to the same value so preferred-book matching is robust. */
|
||||
function bookKey(book) {
|
||||
return String(bookInfo(book).name || book || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** Is `book` in the user's preferred list? (accepts ids, codes, or names) */
|
||||
function isPreferredBook(book, preferred) {
|
||||
if (!Array.isArray(preferred) || preferred.length === 0) return false;
|
||||
const k = bookKey(book);
|
||||
return preferred.some((p) => bookKey(p) === k);
|
||||
}
|
||||
|
||||
module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook };
|
||||
|
||||
@@ -17,15 +17,20 @@ const NICKNAMES = {
|
||||
charlie: 'charles', chuck: 'charles', rick: 'richard', dick: 'richard',
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
|
||||
};
|
||||
|
||||
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra".
|
||||
function collapseInitials(s) {
|
||||
return s.replace(/\b([A-Za-z])(?: ([A-Za-z]))+\b(?=\s|$)/g, (m) => m.replace(/ /g, ''));
|
||||
}
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
const display = collapseInitials(String(raw == null ? '' : raw)
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ')
|
||||
.replace(/\./g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
.trim());
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||
return { display, key };
|
||||
|
||||
Reference in New Issue
Block a user