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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user