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:
Kev
2026-06-19 10:20:55 -04:00
parent 91b03c4044
commit 3b47b783dc
17 changed files with 687 additions and 17 deletions
+33 -2
View File
@@ -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 25 — 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
+21
View File
@@ -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)
+2
View File
@@ -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);
+79
View File
@@ -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;
+9 -3
View File
@@ -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 };
+77
View File
@@ -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
});
});
+30
View File
@@ -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);
});
});
+94
View File
@@ -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&apos;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
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -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 });
}
}
+30 -2
View File
@@ -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. */}
+165
View File
@@ -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&apos;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&apos;re locked in.</h1>
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>Taking you to your slate</p>
</div>
)}
</section>
);
}
+79 -2
View File
@@ -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>
+4 -1
View File
@@ -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')}
/>
))}
+5 -2
View File
@@ -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
View File
@@ -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 };
+8 -3
View File
@@ -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 };