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
+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>