Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
|
||||
export type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
interface UserProfile {
|
||||
tier: Tier;
|
||||
scan_count: number;
|
||||
scan_reset_date: string;
|
||||
founder_pricing: boolean;
|
||||
subscription_status: 'none' | 'active' | 'grace_period' | 'expired' | 'canceled';
|
||||
subscription_end: string | null;
|
||||
mfa_setup_prompted: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
profile: UserProfile | null;
|
||||
tier: Tier;
|
||||
scanCount: number;
|
||||
scansRemaining: number | null;
|
||||
canScan: boolean;
|
||||
loading: boolean;
|
||||
signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
||||
signInWithGoogle: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
bumpScanCount: () => void;
|
||||
// Marks the MFA setup nag as seen so we don't ask the same user again.
|
||||
// Independent of whether they actually enabled MFA.
|
||||
markMFAPrompted: () => Promise<void>;
|
||||
}
|
||||
|
||||
const FREE_LIMIT = 5; // reads per calendar month
|
||||
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01'; // YYYY-MM-01
|
||||
const isSameMonth = (date: string | null | undefined) =>
|
||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export default function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const supabase = getBrowserSupabase();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadProfile = useCallback(
|
||||
async (currentUser: User | null) => {
|
||||
if (!supabase || !currentUser) {
|
||||
setProfile(null);
|
||||
return;
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('tier, scan_count, scan_reset_date, founder_pricing, subscription_status, subscription_end, mfa_setup_prompted')
|
||||
.eq('id', currentUser.id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
setProfile({
|
||||
tier: 'free',
|
||||
scan_count: 0,
|
||||
scan_reset_date: monthKey(),
|
||||
founder_pricing: false,
|
||||
subscription_status: 'none',
|
||||
subscription_end: null,
|
||||
mfa_setup_prompted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const thisMonth = monthKey();
|
||||
const needsReset = !isSameMonth(data.scan_reset_date);
|
||||
setProfile({
|
||||
tier: (data.tier as Tier) || 'free',
|
||||
scan_count: needsReset ? 0 : data.scan_count || 0,
|
||||
scan_reset_date: thisMonth,
|
||||
founder_pricing: !!data.founder_pricing,
|
||||
subscription_status: data.subscription_status || 'none',
|
||||
subscription_end: data.subscription_end,
|
||||
mfa_setup_prompted: !!data.mfa_setup_prompted,
|
||||
});
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setSession(data.session);
|
||||
setUser(data.session?.user ?? null);
|
||||
loadProfile(data.session?.user ?? null).finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
setSession(newSession);
|
||||
setUser(newSession?.user ?? null);
|
||||
void loadProfile(newSession?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, [supabase, loadProfile]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await loadProfile(user);
|
||||
}, [loadProfile, user]);
|
||||
|
||||
const bumpScanCount = useCallback(() => {
|
||||
setProfile((p) => (p ? { ...p, scan_count: p.scan_count + 1 } : p));
|
||||
}, []);
|
||||
|
||||
const signUp = useCallback<AuthContextValue['signUp']>(
|
||||
async (email, password, ageVerified) => {
|
||||
if (!ageVerified) return { error: 'You must confirm you are 21 or older.' };
|
||||
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
|
||||
});
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
const signIn = useCallback<AuthContextValue['signIn']>(
|
||||
async (email, password) => {
|
||||
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
const signInWithGoogle = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||
});
|
||||
}, [supabase]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
await supabase.auth.signOut();
|
||||
setProfile(null);
|
||||
}, [supabase]);
|
||||
|
||||
const markMFAPrompted = useCallback(async () => {
|
||||
if (!supabase || !user) return;
|
||||
setProfile((p) => (p ? { ...p, mfa_setup_prompted: true } : p));
|
||||
await supabase.from('user_profiles').update({ mfa_setup_prompted: true }).eq('id', user.id);
|
||||
}, [supabase, user]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const tier = profile?.tier ?? 'free';
|
||||
const scanCount = profile?.scan_count ?? 0;
|
||||
const scansRemaining = tier === 'free' ? Math.max(0, FREE_LIMIT - scanCount) : null;
|
||||
const canScan = tier !== 'free' || scansRemaining === null || (scansRemaining ?? 0) > 0;
|
||||
|
||||
return {
|
||||
user,
|
||||
session,
|
||||
profile,
|
||||
tier,
|
||||
scanCount,
|
||||
scansRemaining,
|
||||
canScan,
|
||||
loading,
|
||||
signUp,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
refresh,
|
||||
bumpScanCount,
|
||||
markMFAPrompted,
|
||||
};
|
||||
}, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signOut, refresh, bumpScanCount, markMFAPrompted]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
tier: 'free',
|
||||
scanCount: 0,
|
||||
scansRemaining: 5,
|
||||
canScan: true,
|
||||
loading: false,
|
||||
signUp: async () => ({ error: 'Auth not initialized' }),
|
||||
signIn: async () => ({ error: 'Auth not initialized' }),
|
||||
signInWithGoogle: async () => {},
|
||||
signOut: async () => {},
|
||||
refresh: async () => {},
|
||||
bumpScanCount: () => {},
|
||||
markMFAPrompted: async () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// "Explain Like I'm New" toggle. When on, every annotated UI element renders
|
||||
// a small tooltip explaining what the number/grade/line actually means. The
|
||||
// preference is per-browser, stored in localStorage.
|
||||
|
||||
interface ExplainModeContextValue {
|
||||
explainMode: boolean;
|
||||
toggleExplainMode: () => void;
|
||||
setExplainMode: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vyndr_explain_mode';
|
||||
const ExplainModeContext = createContext<ExplainModeContextValue | null>(null);
|
||||
|
||||
export default function ExplainModeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [explainMode, setExplainModeState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === '1') setExplainModeState(true);
|
||||
}, []);
|
||||
|
||||
const setExplainMode = useCallback((next: boolean) => {
|
||||
setExplainModeState(next);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, next ? '1' : '0');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleExplainMode = useCallback(() => {
|
||||
setExplainMode(!explainMode);
|
||||
}, [explainMode, setExplainMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ explainMode, toggleExplainMode, setExplainMode }),
|
||||
[explainMode, toggleExplainMode, setExplainMode]
|
||||
);
|
||||
|
||||
return <ExplainModeContext.Provider value={value}>{children}</ExplainModeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useExplainMode(): ExplainModeContextValue {
|
||||
const ctx = useContext(ExplainModeContext);
|
||||
if (!ctx) {
|
||||
// SSR / outside-provider fallback. Treating off as the safe default.
|
||||
return {
|
||||
explainMode: false,
|
||||
toggleExplainMode: () => {},
|
||||
setExplainMode: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface ParlayLeg {
|
||||
id: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface ParlayContextValue {
|
||||
legs: ParlayLeg[];
|
||||
legCount: number;
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
addLeg: (leg: Omit<ParlayLeg, 'id'>) => void;
|
||||
removeLeg: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bbk:parlay';
|
||||
const MAX_LEGS = 12;
|
||||
|
||||
const ParlayContext = createContext<ParlayContextValue | null>(null);
|
||||
|
||||
export default function ParlayProvider({ children }: { children: React.ReactNode }) {
|
||||
const [legs, setLegs] = useState<ParlayLeg[]>([]);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
// Restore from localStorage so a refresh doesn't drop the tray
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) setLegs(parsed);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(legs));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}, [legs]);
|
||||
|
||||
const addLeg = useCallback((leg: Omit<ParlayLeg, 'id'>) => {
|
||||
setLegs((prev) => {
|
||||
if (prev.length >= MAX_LEGS) return prev;
|
||||
// De-dupe by player+stat+line+direction
|
||||
const key = `${leg.player}|${leg.stat}|${leg.line}|${leg.direction}`;
|
||||
if (prev.some((p) => `${p.player}|${p.stat}|${p.line}|${p.direction}` === key)) return prev;
|
||||
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const next = [...prev, { ...leg, id }];
|
||||
// Fire-and-forget: tell the backend so most-parlayed counts get bumped
|
||||
void fetch('/api/parlay/add-leg', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sport: leg.sport,
|
||||
player: leg.player,
|
||||
stat: leg.stat,
|
||||
line: leg.line,
|
||||
direction: leg.direction,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeLeg = useCallback((id: string) => {
|
||||
setLegs((prev) => prev.filter((l) => l.id !== id));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setLegs([]), []);
|
||||
|
||||
const value = useMemo<ParlayContextValue>(() => ({
|
||||
legs,
|
||||
legCount: legs.length,
|
||||
isOpen,
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
toggle: () => setOpen((o) => !o),
|
||||
addLeg,
|
||||
removeLeg,
|
||||
clear,
|
||||
}), [legs, isOpen, addLeg, removeLeg, clear]);
|
||||
|
||||
return <ParlayContext.Provider value={value}>{children}</ParlayContext.Provider>;
|
||||
}
|
||||
|
||||
export function useParlay(): ParlayContextValue {
|
||||
const ctx = useContext(ParlayContext);
|
||||
if (!ctx) {
|
||||
// Provide a noop fallback so components can render outside the provider
|
||||
// (e.g. during prerender of marketing pages).
|
||||
return {
|
||||
legs: [],
|
||||
legCount: 0,
|
||||
isOpen: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
addLeg: () => {},
|
||||
removeLeg: () => {},
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user