Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { createPaymentLink, TIER_PRICING, type NexaPayTier } from '@/services/nexapay';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const VALID_TIERS = new Set<NexaPayTier>(['analyst', 'desk']);
|
||||
|
||||
async function resolveTier(req: NextRequest): Promise<NexaPayTier | null> {
|
||||
const url = new URL(req.url);
|
||||
const queryTier = url.searchParams.get('tier');
|
||||
if (queryTier && VALID_TIERS.has(queryTier as NexaPayTier)) return queryTier as NexaPayTier;
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = (await req.json().catch(() => ({}))) as { tier?: string };
|
||||
if (body.tier && VALID_TIERS.has(body.tier as NexaPayTier)) return body.tier as NexaPayTier;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to upgrade.');
|
||||
|
||||
const tier = await resolveTier(req);
|
||||
if (!tier) return jsonError(400, 'Pick a valid tier (analyst or desk).');
|
||||
|
||||
// Founder pricing eligibility — first 100 paid users overall
|
||||
let founderEligible = false;
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
const { count } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('founder_pricing', true);
|
||||
founderEligible = (count ?? 0) < 100;
|
||||
}
|
||||
|
||||
const pricing = TIER_PRICING[tier];
|
||||
const amount = founderEligible ? pricing.founder : pricing.regular;
|
||||
|
||||
try {
|
||||
const link = await createPaymentLink({
|
||||
userId: user.id,
|
||||
tier,
|
||||
amount,
|
||||
description: `${pricing.label}${founderEligible ? ' (Founder)' : ''}`,
|
||||
founderPricing: founderEligible,
|
||||
});
|
||||
|
||||
// For GET (used by Pricing CTA links), redirect directly.
|
||||
if (req.method === 'GET') {
|
||||
return NextResponse.redirect(link.url, { status: 303 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: link.url, expires_at: link.expires_at, founder_pricing: founderEligible });
|
||||
} catch (err) {
|
||||
console.error('[checkout] NexaPay link failed', err);
|
||||
return jsonError(502, 'Payment processor is unreachable. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ props: [] });
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
`game:props:${id}`,
|
||||
'mixed',
|
||||
'game_props',
|
||||
`/api/games/${encodeURIComponent(id)}/props`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ error: 'Missing game id.' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const game = await cachedBackendJson<Record<string, unknown>>(
|
||||
`game:detail:${id}`,
|
||||
'mixed',
|
||||
'game_detail',
|
||||
`/api/games/${encodeURIComponent(id)}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(game);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Game not found.' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
ab_grade_count?: number;
|
||||
injury_note?: string;
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', games: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const games = await cachedBackendJson<Game[]>(
|
||||
todayKey(sport, 'games'),
|
||||
sport,
|
||||
'games',
|
||||
`/api/games/tonight?sport=${sport}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ games: Array.isArray(games) ? games : [] },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' } },
|
||||
);
|
||||
} catch {
|
||||
// The slate genuinely may be empty (off-day). Return empty list so the UI
|
||||
// shows the branded empty state instead of an error.
|
||||
return NextResponse.json({ games: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// The page itself shows the blurred preview for non-Desk users, so we
|
||||
// still return a small set of signals (so the timeline scaffolding
|
||||
// looks alive behind the blur). For Desk users, return full feed.
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const limit = user.tier === 'desk' ? 50 : 8;
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ signals: unknown[] }>(
|
||||
`intelligence:feed:${limit}`,
|
||||
'mixed',
|
||||
'intelligence_feed',
|
||||
`/api/intelligence/feed?limit=${limit}`,
|
||||
60,
|
||||
);
|
||||
return NextResponse.json({ signals: Array.isArray(data?.signals) ? data.signals : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ signals: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await cachedBackendJson<{ buckets: unknown[] }>(
|
||||
'ledger:accuracy:rolling',
|
||||
'mixed',
|
||||
'ledger_accuracy',
|
||||
'/api/ledger/accuracy',
|
||||
600,
|
||||
);
|
||||
return NextResponse.json({ buckets: Array.isArray(data?.buckets) ? data.buckets : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ buckets: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_TIERS = new Set(['A', 'B', 'C', 'D']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || '').toUpperCase();
|
||||
const tier = (req.nextUrl.searchParams.get('tier') || '').toUpperCase();
|
||||
const limit = Math.min(60, Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 30)));
|
||||
|
||||
if (sport && !VALID_SPORTS.has(sport)) return NextResponse.json({ entries: [] });
|
||||
if (tier && !VALID_TIERS.has(tier)) return NextResponse.json({ entries: [] });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (sport) params.set('sport', sport);
|
||||
if (tier) params.set('tier', tier);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ entries: unknown[] }>(
|
||||
todayKey(sport || 'all', `ledger:${tier || 'all'}:${limit}`),
|
||||
sport || 'mixed',
|
||||
'ledger',
|
||||
`/api/ledger?${params}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ entries: Array.isArray(data?.entries) ? data.entries : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ entries: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Body {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player?: string;
|
||||
stat?: string;
|
||||
line?: number;
|
||||
direction?: 'over' | 'under';
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_DIRS = new Set(['over', 'under']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
!body.sport || !VALID_SPORTS.has(body.sport) ||
|
||||
!body.player || typeof body.player !== 'string' ||
|
||||
!body.stat ||
|
||||
typeof body.line !== 'number' || !Number.isFinite(body.line) ||
|
||||
!body.direction || !VALID_DIRS.has(body.direction)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Missing or invalid leg fields.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ ok: true, persisted: false });
|
||||
|
||||
// RPC increments scan or parlay counter atomically.
|
||||
await sb.rpc('increment_parlay_leg_frequency', {
|
||||
p_player: body.player,
|
||||
p_stat: body.stat,
|
||||
p_line: body.line,
|
||||
p_dir: body.direction,
|
||||
p_sport: body.sport,
|
||||
p_scan_delta: 0,
|
||||
p_parlay_delta: 1,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Leg {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
}
|
||||
|
||||
interface Body {
|
||||
legs: Leg[];
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to grade parlays.');
|
||||
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.legs) || body.legs.length < 2 || body.legs.length > 12) {
|
||||
return jsonError(400, 'Send 2–12 legs.');
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/scan/parlay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: body.legs }),
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { error?: string }).error || 'The engine hit a wall on this parlay.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return jsonError(502, 'The engine hit a wall on this parlay.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const NBA_SERVICE = process.env.NBA_SERVICE_URL || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
const q = (req.nextUrl.searchParams.get('q') || '').trim();
|
||||
const gameId = req.nextUrl.searchParams.get('game_id') || '';
|
||||
|
||||
if (q.length < 2) return NextResponse.json({ players: [] });
|
||||
|
||||
try {
|
||||
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
|
||||
const url =
|
||||
sport === 'MLB'
|
||||
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
|
||||
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
|
||||
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return NextResponse.json({ players: [] });
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
|
||||
? (data as { results: unknown[] }).results
|
||||
: Array.isArray((data as { players?: unknown[] }).players)
|
||||
? (data as { players: unknown[] }).players
|
||||
: [];
|
||||
|
||||
const players: Player[] = rawPlayers.slice(0, 12).map((p) => {
|
||||
const obj = (p && typeof p === 'object' ? p : {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(obj.id ?? obj.player_id ?? obj.full_name ?? Math.random()),
|
||||
full_name: String(obj.full_name ?? obj.name ?? ''),
|
||||
team: typeof obj.team === 'string' ? obj.team : undefined,
|
||||
position: typeof obj.position === 'string' ? obj.position : undefined,
|
||||
};
|
||||
}).filter((p) => p.full_name);
|
||||
|
||||
return NextResponse.json({ players });
|
||||
} catch {
|
||||
return NextResponse.json({ players: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface LiveProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
graded_at: string;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<LiveProp[] | { error: string }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/props/live`, {
|
||||
next: { revalidate: 60 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Backend unavailable — return empty list, the UI shows the fallback line.
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LiveProp[];
|
||||
if (!Array.isArray(data)) {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
return NextResponse.json(data.slice(0, 24), { headers: cacheHeaders() });
|
||||
} catch {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
function cacheHeaders() {
|
||||
return { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface ParlayedProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
parlay_count: number;
|
||||
scan_count: number;
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ props: [] });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const { data } = await sb
|
||||
.from('parlay_leg_frequency')
|
||||
.select('player_name, stat, line, over_under, sport, parlay_count, scan_count')
|
||||
.eq('game_date', today)
|
||||
.order('parlay_count', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (!data) return NextResponse.json({ props: [] });
|
||||
|
||||
const props: ParlayedProp[] = data.map((row) => ({
|
||||
player: row.player_name,
|
||||
stat: row.stat,
|
||||
line: Number(row.line),
|
||||
direction: row.over_under as 'over' | 'under',
|
||||
sport: row.sport as 'NBA' | 'MLB' | 'WNBA',
|
||||
parlay_count: row.parlay_count,
|
||||
scan_count: row.scan_count,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ props });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 120;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', props: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
todayKey(sport, 'top_graded'),
|
||||
sport,
|
||||
'top_graded',
|
||||
`/api/props/top-graded?sport=${sport}`,
|
||||
120,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { rateLimitCheck, rateLimitKey, rateLimitResponse } from '@/middleware/rateLimit';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const FREE_LIMIT = 5; // reads per calendar month
|
||||
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01';
|
||||
const isSameMonth = (date: string | null | undefined) =>
|
||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||
const VALID_NBA_STATS = new Set(['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers']);
|
||||
const VALID_MLB_STATS = new Set([
|
||||
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
||||
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
|
||||
]);
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ScanBody {
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
book?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ScanBody;
|
||||
try {
|
||||
body = (await req.json()) as ScanBody;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON body.');
|
||||
}
|
||||
|
||||
if (!VALID_SPORTS.has(body.sport)) return jsonError(400, 'Unknown sport.');
|
||||
if (!VALID_DIRECTIONS.has(body.direction)) return jsonError(400, 'Direction must be over or under.');
|
||||
if (typeof body.player !== 'string' || body.player.length === 0 || body.player.length > 80) {
|
||||
return jsonError(400, 'Player name is required.');
|
||||
}
|
||||
if (typeof body.line !== 'number' || !Number.isFinite(body.line) || body.line < 0 || body.line > 500) {
|
||||
return jsonError(400, 'Line must be a number between 0 and 500.');
|
||||
}
|
||||
|
||||
const validStats = body.sport === 'MLB' ? VALID_MLB_STATS : VALID_NBA_STATS;
|
||||
if (!validStats.has(body.stat)) {
|
||||
return jsonError(400, `Stat "${body.stat}" not supported for ${body.sport}.`);
|
||||
}
|
||||
|
||||
const user = await getUserFromRequest(req);
|
||||
const sb = getServiceRoleSupabase();
|
||||
|
||||
// Per-minute rate limit (different limit per tier)
|
||||
const rl = rateLimitCheck(rateLimitKey(req), user?.tier ?? 'free');
|
||||
if (!rl.ok) return rateLimitResponse(rl.retryAfter);
|
||||
|
||||
// Throttle free tier (monthly cap)
|
||||
if (user && user.tier === 'free' && sb) {
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
|
||||
|
||||
if (usedThisMonth >= FREE_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "You've used your 5 free reads this month. Unlock unlimited reads and full intelligence — Founder Access, $14.99/mo.",
|
||||
scans_remaining: 0,
|
||||
upgrade: { tier: 'analyst', price: 14.99 },
|
||||
},
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to backend grading engine
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/analyze/prop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sport: body.sport,
|
||||
player: body.player,
|
||||
stat_type: body.stat,
|
||||
line: body.line,
|
||||
direction: body.direction,
|
||||
book: body.book ?? 'draftkings',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data?.error || 'The engine hit a wall. Try that read again.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
|
||||
let scansRemaining: number | null = null;
|
||||
|
||||
if (user && sb) {
|
||||
void sb.rpc('increment_parlay_leg_frequency', {
|
||||
p_player: body.player,
|
||||
p_stat: body.stat,
|
||||
p_line: body.line,
|
||||
p_dir: body.direction,
|
||||
p_sport: body.sport,
|
||||
p_scan_delta: 1,
|
||||
p_parlay_delta: 0,
|
||||
});
|
||||
|
||||
void sb.from('scan_history').insert({
|
||||
user_id: user.id,
|
||||
sport: body.sport,
|
||||
player_name: body.player,
|
||||
stat: body.stat,
|
||||
line: body.line,
|
||||
direction: body.direction,
|
||||
grade: data.grade,
|
||||
projection: data.projection,
|
||||
confidence: data.confidence,
|
||||
factors: data.factors ?? null,
|
||||
});
|
||||
|
||||
if (user.tier === 'free') {
|
||||
const thisMonth = monthKey();
|
||||
const { data: current } = await sb
|
||||
.from('user_profiles')
|
||||
.select('scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const next = (isSameMonth(current?.scan_reset_date) ? (current?.scan_count ?? 0) : 0) + 1;
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ scan_count: next, scan_reset_date: thisMonth })
|
||||
.eq('id', user.id);
|
||||
scansRemaining = Math.max(0, FREE_LIMIT - next);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...data, scans_remaining: scansRemaining, tier: user?.tier ?? 'free' });
|
||||
} catch (err) {
|
||||
console.error('[scan] backend call failed', err);
|
||||
return jsonError(502, 'The engine hit a wall. Try that read again.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<{ count: number }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/parlays-graded`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ count: 0 });
|
||||
const data = (await res.json()) as { count?: number };
|
||||
return NextResponse.json({ count: Number(data.count || 0) });
|
||||
} catch {
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface PublicStats {
|
||||
parlays_graded: number;
|
||||
kill_conditions_caught: number;
|
||||
a_grade_accuracy: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
const FALLBACK: PublicStats = {
|
||||
parlays_graded: 0,
|
||||
kill_conditions_caught: 0,
|
||||
a_grade_accuracy: 0,
|
||||
last_updated: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<PublicStats>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/public`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
const data = (await res.json()) as Partial<PublicStats>;
|
||||
return NextResponse.json(
|
||||
{
|
||||
parlays_graded: Number(data.parlays_graded || 0),
|
||||
kill_conditions_caught: Number(data.kill_conditions_caught || 0),
|
||||
a_grade_accuracy: Number(data.a_grade_accuracy || 0),
|
||||
last_updated: data.last_updated || new Date().toISOString(),
|
||||
},
|
||||
{ headers: cache() },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
}
|
||||
}
|
||||
|
||||
function cache() {
|
||||
return { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=120' };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id, email, tier, scan_count, scan_reset_date, subscription_status, subscription_end, founder_pricing, cancel_at_period_end')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return jsonError(500, error.message);
|
||||
}
|
||||
return NextResponse.json(data ?? { id: user.id, email: user.email, tier: 'free' });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
let body: { age_verified?: boolean; cancel_at_period_end?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON body.');
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (typeof body.age_verified === 'boolean') update.age_verified = body.age_verified;
|
||||
if (typeof body.cancel_at_period_end === 'boolean') update.cancel_at_period_end = body.cancel_at_period_end;
|
||||
|
||||
if (Object.keys(update).length === 0) return jsonError(400, 'Nothing to update.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.update(update)
|
||||
.eq('id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ scans: [] });
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('scan_history')
|
||||
.select('id, sport, player_name, stat, line, direction, grade, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json({ scans: data ?? [] });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const FREE_LIMIT = 5; // reads per calendar month
|
||||
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01';
|
||||
const isSameMonth = (date: string | null | undefined) =>
|
||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('tier, scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const tier = (profile?.tier as 'free' | 'analyst' | 'desk') ?? 'free';
|
||||
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
|
||||
const remaining = tier === 'free' ? Math.max(0, FREE_LIMIT - usedThisMonth) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
used_this_month: usedThisMonth,
|
||||
remaining,
|
||||
limit: tier === 'free' ? FREE_LIMIT : null,
|
||||
reset_date: monthKey(),
|
||||
period: 'monthly',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ALLOWED_LISTS = new Set(['merch', 'ledger-book', 'The Line', 'The Edge', 'The Correlation', 'The System', 'general']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: { email?: string; list?: string; honeypot?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
// Honeypot — silently accept then drop
|
||||
if (body.honeypot) return NextResponse.json({ ok: true });
|
||||
|
||||
if (!body.email || !EMAIL_RE.test(body.email)) {
|
||||
return jsonError(400, 'Enter a valid email.');
|
||||
}
|
||||
const list = body.list && ALLOWED_LISTS.has(body.list) ? body.list : 'general';
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
await sb
|
||||
.from('waitlist_signups')
|
||||
.insert({ email: body.email.toLowerCase(), list, source: 'web' })
|
||||
.select()
|
||||
.maybeSingle();
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, list });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { verifyWebhookSignature, type NexaPayWebhookEvent, type NexaPayTier } from '@/services/nexapay';
|
||||
import { sendPaymentReceipt } from '@/services/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// We need the raw body to verify the HMAC signature.
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const signature = req.headers.get('x-nexapay-signature');
|
||||
|
||||
if (!verifyWebhookSignature(rawBody, signature)) {
|
||||
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let event: NexaPayWebhookEvent;
|
||||
try {
|
||||
event = JSON.parse(rawBody) as NexaPayWebhookEvent;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) {
|
||||
console.error('[nexapay webhook] Supabase service role not configured');
|
||||
return NextResponse.json({ error: 'misconfigured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const userId = event.data.metadata?.userId;
|
||||
const tier = event.data.metadata?.tier as NexaPayTier | undefined;
|
||||
const founderPricing = event.data.metadata?.founderPricing === 'true';
|
||||
|
||||
if (!userId || !tier) {
|
||||
return NextResponse.json({ ok: true, ignored: 'missing metadata' });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment.succeeded': {
|
||||
const subscription_end = new Date();
|
||||
subscription_end.setUTCDate(subscription_end.getUTCDate() + 30);
|
||||
|
||||
const { error } = await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
tier,
|
||||
subscription_status: 'active',
|
||||
subscription_start: new Date().toISOString(),
|
||||
subscription_end: subscription_end.toISOString(),
|
||||
cancel_at_period_end: false,
|
||||
founder_pricing: founderPricing,
|
||||
nexapay_customer_id: event.data.customer_id ?? null,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('[nexapay webhook] update failed', error);
|
||||
return NextResponse.json({ error: 'update_failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget receipt email. Don't block the webhook ACK.
|
||||
const { data: profileRow } = await sb
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (profileRow?.email) {
|
||||
void sendPaymentReceipt(profileRow.email, {
|
||||
tier,
|
||||
amount: `$${(event.data.amount / 100).toFixed(2)}`,
|
||||
renewsAt: subscription_end.toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.failed': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ subscription_status: 'grace_period' })
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.refunded':
|
||||
case 'subscription.canceled': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
subscription_status: 'canceled',
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
|
||||
function CallbackInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Supabase JS picks up the access_token from the URL fragment automatically
|
||||
// (detectSessionInUrl: true). We wait for the session to materialize and
|
||||
// then route forward.
|
||||
const sb = getBrowserSupabase();
|
||||
if (!sb) {
|
||||
router.replace('/login?error=auth-not-configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const next = search.get('next') || '/dashboard';
|
||||
|
||||
const { data: sub } = sb.auth.onAuthStateChange((event) => {
|
||||
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED' || event === 'INITIAL_SESSION') {
|
||||
sb.auth.getSession().then(({ data }) => {
|
||||
if (data.session?.access_token) {
|
||||
// Keep the legacy localStorage token in sync for any older fetch
|
||||
// helpers that still read `sb-token` directly.
|
||||
localStorage.setItem('sb-token', data.session.access_token);
|
||||
if (data.session.refresh_token) {
|
||||
localStorage.setItem('sb-refresh-token', data.session.refresh_token);
|
||||
}
|
||||
router.replace(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: if no auth event fires within 4s, bail to login.
|
||||
const timer = setTimeout(() => router.replace('/login?error=callback-timeout'), 4000);
|
||||
return () => {
|
||||
sub.subscription.unsubscribe();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [router, search]);
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Signing you in…
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CallbackInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
const post = getPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return {
|
||||
title: `${post.title} — BetonBLK Blog`,
|
||||
title: `${post.title} — VYNDR Blog`,
|
||||
description: post.description,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
@@ -62,7 +62,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
datePublished: post.date,
|
||||
author: { '@type': 'Organization', name: 'BetonBLK' },
|
||||
author: { '@type': 'Organization', name: 'VYNDR' },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getAllPosts } from '@/lib/blog';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog — BetonBLK',
|
||||
description: 'Betting strategy, prop analysis breakdowns, and product updates from BetonBLK.',
|
||||
title: 'Blog — VYNDR',
|
||||
description: 'Betting strategy, prop analysis breakdowns, and product updates from VYNDR.',
|
||||
};
|
||||
|
||||
export default function BlogIndex() {
|
||||
@@ -23,7 +23,7 @@ export default function BlogIndex() {
|
||||
<a
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="block p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--accent)] transition"
|
||||
className="block p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--cyan)] transition"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--text-muted)] mb-2">
|
||||
<time>{post.date}</time>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
sport: Sport;
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
ab_grade_count?: number;
|
||||
injury_note?: string;
|
||||
}
|
||||
|
||||
interface TopGrade {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Sport;
|
||||
grade: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface ParlayLegStat {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Sport;
|
||||
grade?: string;
|
||||
parlay_count: number;
|
||||
}
|
||||
|
||||
interface RecentScan {
|
||||
id: string;
|
||||
player_name: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
sport: Sport;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(null);
|
||||
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
|
||||
|
||||
// Gate
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/dashboard');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
// Fetch slate when sport changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setGames(null);
|
||||
setTopGrades(null);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
|
||||
fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })),
|
||||
]).then(([gamesData, gradesData]) => {
|
||||
if (cancelled) return;
|
||||
setGames(Array.isArray(gamesData?.games) ? gamesData.games : []);
|
||||
setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
// Most parlayed + recent scans don't depend on sport
|
||||
useEffect(() => {
|
||||
fetch('/api/props/most-parlayed')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setMostParlayed(Array.isArray(data?.props) ? data.props.slice(0, 5) : []))
|
||||
.catch(() => setMostParlayed([]));
|
||||
|
||||
if (user) {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
fetch('/api/user/recent-scans', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setRecentScans(Array.isArray(data?.scans) ? data.scans.slice(0, 5) : []))
|
||||
.catch(() => setRecentScans([]));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const gameCountsBySport = useMemo(() => {
|
||||
// Updated whenever the sport's slate refreshes. We only know counts for
|
||||
// the current sport — others show a dash until clicked.
|
||||
return { [sport]: games?.length ?? 0 } as Partial<Record<Sport, number>>;
|
||||
}, [sport, games]);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstTimer = recentScans?.length === 0;
|
||||
const slateEmpty = games?.length === 0;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
{/* Top welcome strip */}
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 24, gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
Tonight's slate
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 4 }}>
|
||||
{new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }).toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
{tier === 'free' && scansRemaining != null && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{scansRemaining}/5 READS · MO
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sport tabs */}
|
||||
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
|
||||
{SPORT_TABS.map((s) => {
|
||||
const active = s === sport;
|
||||
const count = gameCountsBySport[s];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setSport(s)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${active ? SPORT_COLOR[s] : 'transparent'}`,
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{s}{' '}
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginLeft: 4 }}>
|
||||
{active && count != null ? `(${count})` : '·'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top grades horizontal scroll */}
|
||||
<Section
|
||||
title="Top grades tonight"
|
||||
subtitle="The A-tier picks. Tap to read or add to parlay."
|
||||
>
|
||||
{topGrades === null ? (
|
||||
<SkeletonRow />
|
||||
) : topGrades.length === 0 ? (
|
||||
<p style={emptyCopy}>No grades yet. The model is waiting on lines.</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
padding: '4px 4px 12px',
|
||||
scrollSnapType: 'x mandatory',
|
||||
}}
|
||||
>
|
||||
{topGrades.map((g, i) => (
|
||||
<button
|
||||
key={`${g.player}-${g.stat}-${i}`}
|
||||
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
|
||||
className="surface diagonal-cut surface-hover"
|
||||
style={{
|
||||
minWidth: 200,
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
scrollSnapAlign: 'start',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<SportPill sport={g.sport} />
|
||||
<GradePill grade={g.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Tonight's games */}
|
||||
<Section title={`Tonight's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} games tipping`}>
|
||||
{games === null ? (
|
||||
<SkeletonRow stacked />
|
||||
) : games.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No games on the slate tonight.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
|
||||
Check the Ledger to review past grades, or explore the Leaderboard.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{games.map((g, idx) => (
|
||||
<a
|
||||
key={g.id}
|
||||
href={`/game/${g.id}`}
|
||||
className={`surface surface-hover diagonal-cut animate-fade-up stagger-${(idx % 6) + 1}`}
|
||||
style={{
|
||||
padding: 20,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
border: isFirstTimer && idx === 0 ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
boxShadow: isFirstTimer && idx === 0 ? '0 0 0 4px var(--accent-glow)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>
|
||||
{g.away} @ {g.home}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{formatTime(g.start_time)}
|
||||
{g.injury_note ? ` · ${g.injury_note}` : ' · Full squad'}
|
||||
{g.ab_grade_count != null ? ` · ${g.ab_grade_count} A/B grades` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{g.prop_count ?? '—'} props →
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Most parlayed tonight */}
|
||||
<Section title="Most parlayed tonight" subtitle="What other bettors are stacking." right={<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>🔥 TRENDING</span>}>
|
||||
{mostParlayed === null ? (
|
||||
<SkeletonRow />
|
||||
) : mostParlayed.length === 0 ? (
|
||||
<p style={emptyCopy}>No parlays built yet tonight. Be the first.</p>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{mostParlayed.map((p, i) => (
|
||||
<li key={`${p.player}-${p.stat}-${i}`}
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>🔥 {p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{p.sport} · {p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
{p.grade && <GradePill grade={p.grade} />}
|
||||
<button
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => {
|
||||
addLeg({
|
||||
sport: p.sport,
|
||||
player: p.player,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
direction: p.direction,
|
||||
grade: p.grade ?? 'B',
|
||||
confidence: 55,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
>
|
||||
+ Parlay
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Recent scans OR first-timer onboarding */}
|
||||
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
|
||||
{recentScans === null ? (
|
||||
<SkeletonRow />
|
||||
) : recentScans.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut-strong"
|
||||
style={{
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--accent-light)',
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--grade-a)', marginBottom: 12 }}>
|
||||
WELCOME TO THE LEDGER
|
||||
</p>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
|
||||
Tonight's slate is loaded. {games?.length ?? 0} {games?.length === 1 ? 'game' : 'games'} across 3 sports.
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}>
|
||||
Pick a game and read your first prop — it's on us.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ padding: '12px 24px' }}>
|
||||
Run a read →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{recentScans.map((s) => (
|
||||
<li key={s.id}
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{s.player_name}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{s.sport} · {s.direction} {s.line} {s.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<GradePill grade={s.grade} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, right, children }: { title: string; subtitle: string | null; right?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 2 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SportPill({ sport }: { sport: Sport }) {
|
||||
return (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[sport]}1F`,
|
||||
color: SPORT_COLOR[sport],
|
||||
}}
|
||||
>
|
||||
{sport}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow({ stacked = false }: { stacked?: boolean }) {
|
||||
if (stacked) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 80,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
height: 110,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyCopy: React.CSSProperties = {
|
||||
padding: '24px 16px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 14,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const request = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setError('');
|
||||
if (!email) return setError('Enter your email.');
|
||||
setBusy(true);
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) {
|
||||
setBusy(false);
|
||||
return setError('Auth is not configured.');
|
||||
}
|
||||
const { error: err } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/callback?next=/profile`,
|
||||
});
|
||||
setBusy(false);
|
||||
if (err) return setError(err.message);
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
{!sent ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Reset your password</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-1)', fontSize: 13, marginBottom: 24 }}>
|
||||
Enter your email. We'll send you a reset link.
|
||||
</p>
|
||||
<form onSubmit={request} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="lbl" style={{ display: 'block', marginBottom: 6 }}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Sending…' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<ConfirmationBlock email={email} onResend={request} busy={busy} />
|
||||
)}
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-1)', marginTop: 20 }}>
|
||||
<a href="/login" style={{ color: 'var(--text-1)' }}>← Back to sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationBlock({ email, onResend, busy }: { email: string; onResend: () => void; busy: boolean }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}>
|
||||
<EnvelopeIcon />
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Check your inbox</h2>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 320 }}>
|
||||
We sent a reset link to <span className="mono" style={{ color: 'var(--text-0)' }}>{email}</span>. It expires in 1 hour.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="btn-ghost"
|
||||
style={{ marginTop: 8, fontSize: 13 }}
|
||||
>
|
||||
{busy ? 'Resending…' : "Didn't get it? Resend"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvelopeIcon() {
|
||||
return (
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="var(--grade-a)" strokeWidth="1.5" />
|
||||
<path d="M3 7l9 6 9-6" stroke="var(--grade-a)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Player {
|
||||
name: string;
|
||||
position?: string;
|
||||
injury_status?: 'OUT' | 'DOUBTFUL' | 'QUESTIONABLE' | null;
|
||||
}
|
||||
|
||||
interface GameDetail {
|
||||
id: string;
|
||||
sport: Sport;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
spread?: number;
|
||||
spread_favorite?: 'home' | 'away';
|
||||
total?: number;
|
||||
moneyline_home?: number;
|
||||
moneyline_away?: number;
|
||||
away_lineup?: Player[];
|
||||
home_lineup?: Player[];
|
||||
pace?: number;
|
||||
pace_rank?: number;
|
||||
matchup_notes?: string[];
|
||||
}
|
||||
|
||||
interface PropEntry {
|
||||
id: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: Record<string, string>;
|
||||
kill_conditions?: { code: string; reason: string }[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
alt_lines?: { line: number; grade: string; hit_rate?: number }[];
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function GamePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [props, setProps] = useState<PropEntry[] | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace(`/login?next=/game/${id}`);
|
||||
}, [authLoading, user, id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError('');
|
||||
Promise.all([
|
||||
fetch(`/api/games/${id}`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/games/${id}/props`).then((r) => r.json()).catch(() => ({ props: [] })),
|
||||
]).then(([g, p]) => {
|
||||
if (cancelled) return;
|
||||
if (!g || g.error) {
|
||||
setError(g?.error || 'Game not found.');
|
||||
return;
|
||||
}
|
||||
setGame(g);
|
||||
setProps(Array.isArray(p?.props) ? p.props : []);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (authLoading || !user || (!game && !error)) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading game…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
||||
</p>
|
||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
{/* Back nav */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12, marginBottom: 16 }}
|
||||
>
|
||||
← Slate
|
||||
</button>
|
||||
|
||||
{/* Game header */}
|
||||
<header
|
||||
className="surface diagonal-cut-strong animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 16,
|
||||
borderColor: SPORT_COLOR[game.sport],
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[game.sport]}1F`,
|
||||
color: SPORT_COLOR[game.sport],
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{game.sport}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{formatTime(game.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
|
||||
{game.away} <span style={{ color: 'var(--text-tertiary)' }}>@</span> {game.home}
|
||||
</h1>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{game.spread != null && (
|
||||
<Stat label="Spread" value={`${game.spread_favorite === 'home' ? game.home : game.away} ${game.spread}`} />
|
||||
)}
|
||||
{game.total != null && <Stat label="Total" value={game.total.toString()} />}
|
||||
{game.moneyline_home != null && (
|
||||
<Stat label="Moneyline" value={`${game.home} ${formatOdds(game.moneyline_home)} / ${game.away} ${formatOdds(game.moneyline_away ?? 0)}`} />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Starting lineups */}
|
||||
{(game.away_lineup?.length || game.home_lineup?.length) && (
|
||||
<Card title="Starting lineups">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Lineup teamName={game.away} players={game.away_lineup ?? []} />
|
||||
<Lineup teamName={game.home} players={game.home_lineup ?? []} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Matchup stats */}
|
||||
{game.matchup_notes && game.matchup_notes.length > 0 && (
|
||||
<Card title="Key matchup stats">
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{game.matchup_notes.map((note, i) => (
|
||||
<li key={i} style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
· {note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Props list */}
|
||||
<Card title={`All props for this game`} subtitle={`${props?.length ?? 0} graded`}>
|
||||
{props === null ? (
|
||||
<p style={{ color: 'var(--text-tertiary)' }}>Loading props…</p>
|
||||
) : props.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
No props posted for this game yet. Books usually open player props 2–3 hours before tip. Check back closer to game time.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{props.map((p) => {
|
||||
const isOpen = expanded === p.id;
|
||||
return (
|
||||
<li key={p.id} className="surface" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(isOpen ? null : p.id)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<GradePill grade={p.grade} />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addLeg({
|
||||
sport: game.sport,
|
||||
player: p.player,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
direction: p.direction,
|
||||
grade: p.grade,
|
||||
confidence: p.confidence ?? 55,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||||
aria-label="Add to parlay"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ padding: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<GradeCard
|
||||
sport={game.sport}
|
||||
player={p.player}
|
||||
stat={p.stat}
|
||||
line={p.line}
|
||||
direction={p.direction}
|
||||
grade={p.grade}
|
||||
projection={p.projection}
|
||||
confidence={p.confidence}
|
||||
sample_size={p.sample_size}
|
||||
factors={p.factors}
|
||||
alt_lines={p.alt_lines}
|
||||
kill_conditions={p.kill_conditions}
|
||||
reasoning={p.reasoning}
|
||||
historical_hit_rate={p.historical_hit_rate}
|
||||
tier={tier}
|
||||
onUpgradeClick={(target) => router.push(`/api/checkout?tier=${target}`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<a
|
||||
href={`/scan?sport=${game.sport}`}
|
||||
className="btn-primary"
|
||||
style={{ padding: '12px 24px' }}
|
||||
>
|
||||
Read a custom prop →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<header style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{subtitle}</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-surface)', borderRadius: 10, border: '1px solid var(--border)' }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 13, fontWeight: 700, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lineup({ teamName, players }: { teamName: string; players: Player[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||
{teamName.toUpperCase()}
|
||||
</div>
|
||||
<ul style={{ display: 'grid', gap: 6 }}>
|
||||
{players.map((p) => (
|
||||
<li key={p.name} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 8 }}>
|
||||
<span>{p.name}</span>
|
||||
<span className="mono" style={{
|
||||
fontSize: 11,
|
||||
color: p.injury_status === 'OUT' ? 'var(--grade-d)' : p.injury_status ? 'var(--grade-c)' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{p.injury_status ? `${p.injury_status === 'OUT' ? '❌' : '⚠'} ${p.injury_status}` : p.position}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOdds(odds: number): string {
|
||||
if (!odds) return '—';
|
||||
return odds > 0 ? `+${odds}` : `${odds}`;
|
||||
}
|
||||
+746
-15
@@ -1,24 +1,755 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
VYNDR Design System
|
||||
Bloomberg terminal + ESPN broadcast + glitch-in-the-matrix.
|
||||
"The terminal at sportsbook prices."
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--card: #141414;
|
||||
--border: #222222;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888888;
|
||||
--grade-a: #22c55e;
|
||||
--grade-b: #eab308;
|
||||
--grade-c: #f97316;
|
||||
--grade-d: #ef4444;
|
||||
--accent: #3b82f6;
|
||||
/* Surfaces — powered-on screen, slightly warmer than pure black */
|
||||
--bg-0: #06060B;
|
||||
--bg-1: #0E0E16;
|
||||
--bg-2: #15151F;
|
||||
--bg-3: #1A1A26;
|
||||
|
||||
/* Borders */
|
||||
--border: #1E1E2E;
|
||||
--border-hi: #2A2A3E;
|
||||
|
||||
/* Text */
|
||||
--text-0: #E8E8F0;
|
||||
--text-1: #7A7A8E;
|
||||
--text-2: #4A4A5E;
|
||||
|
||||
/* Brand accent — deep forest, button green, glow */
|
||||
--acc-0: #0F3D2E;
|
||||
--acc-1: #1A5A42;
|
||||
--acc-glow: rgba(15, 61, 46, 0.25);
|
||||
|
||||
/* Grade tiers — the most important colors */
|
||||
--grade-aplus: #00FFB8;
|
||||
--grade-a: #00D4A0;
|
||||
--grade-b: #4A9EFF;
|
||||
--grade-c: #FFB347;
|
||||
--grade-d: #FF5252;
|
||||
|
||||
/* Sport tints */
|
||||
--nba: #E94B3C;
|
||||
--mlb: #1E90FF;
|
||||
--wnba: #F7944A;
|
||||
|
||||
/* Special — Vendetta crimson; kill conditions only */
|
||||
--crimson: #8B0000;
|
||||
|
||||
/* Signals */
|
||||
--danger: var(--grade-d);
|
||||
--warning: var(--grade-c);
|
||||
--success: var(--grade-a);
|
||||
|
||||
/* Geometry */
|
||||
--diagonal-angle: -2deg;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Glitch intensity — 0..1, can be tuned per-section */
|
||||
--glitch: 0.5;
|
||||
--scan-opacity: calc(0.04 + 0.06 * var(--glitch));
|
||||
--grain-opacity: calc(0.02 + 0.02 * var(--glitch));
|
||||
--rgb-shift: calc(0.5px + 1.5px * var(--glitch));
|
||||
--slash-opacity: calc(0.06 + 0.08 * var(--glitch));
|
||||
--sweep-opacity: calc(0.08 + 0.12 * var(--glitch));
|
||||
|
||||
/* ── Legacy aliases — every existing component still resolves ── */
|
||||
--bg-primary: var(--bg-0);
|
||||
--bg-surface: var(--bg-1);
|
||||
--bg-surface-hover: var(--bg-2);
|
||||
--bg-elevated: var(--bg-3);
|
||||
--border-focus: var(--border-hi);
|
||||
--text-primary: var(--text-0);
|
||||
--text-secondary: var(--text-1);
|
||||
--text-tertiary: var(--text-2);
|
||||
--accent: var(--acc-0);
|
||||
--accent-light: var(--acc-1);
|
||||
--accent-glow: var(--acc-glow);
|
||||
--bg: var(--bg-0);
|
||||
--card: var(--bg-1);
|
||||
--card-hover: var(--bg-2);
|
||||
--border-light: var(--border-hi);
|
||||
--text: var(--text-0);
|
||||
--text-muted: var(--text-1);
|
||||
--text-dim: var(--text-2);
|
||||
--cyan: var(--grade-a);
|
||||
--cyan-hover: var(--grade-aplus);
|
||||
--cyan-dim: rgba(0, 212, 160, 0.10);
|
||||
--kill: var(--crimson);
|
||||
--forest: var(--acc-0);
|
||||
--forest-dark: #0A2A20;
|
||||
--forest-light: var(--acc-1);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Base
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg-primary);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-0);
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
.font-mono,
|
||||
.mono {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.num {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lbl {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-focus);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
The Diagonal Cut — signature visual motif
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.diagonal-cut {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.diagonal-cut::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(var(--diagonal-angle), transparent 0%, var(--accent-glow) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.diagonal-cut > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.diagonal-cut-strong::before {
|
||||
background: linear-gradient(var(--diagonal-angle), transparent 0%, rgba(26, 74, 58, 0.20) 100%);
|
||||
}
|
||||
|
||||
/* Radial accent glow — for hero backgrounds */
|
||||
.radial-glow {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 50% 30%, var(--accent-glow), transparent 70%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Surfaces
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.surface {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: transform 200ms ease, border-color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.surface-hover:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--border-focus);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.surface-elevated {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-focus);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Buttons
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent-light);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease, transform 100ms ease, box-shadow 200ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-light);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 200ms ease, background 200ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--border-focus);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Inputs
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
transition: border-color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-light);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Grade tier helpers
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.grade-a-tier { color: var(--grade-a); }
|
||||
.grade-b-tier { color: var(--grade-b); }
|
||||
.grade-c-tier { color: var(--grade-c); }
|
||||
.grade-d-tier { color: var(--grade-d); }
|
||||
|
||||
.grade-a-bg { background: rgba(0, 200, 150, 0.10); border-color: rgba(0, 200, 150, 0.30); }
|
||||
.grade-b-bg { background: rgba(74, 158, 255, 0.10); border-color: rgba(74, 158, 255, 0.30); }
|
||||
.grade-c-bg { background: rgba(255, 179, 71, 0.10); border-color: rgba(255, 179, 71, 0.30); }
|
||||
.grade-d-bg { background: rgba(255, 107, 107, 0.10); border-color: rgba(255, 107, 107, 0.30); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Animations
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes grade-reveal {
|
||||
0% { transform: scale(0.85); opacity: 0; }
|
||||
60% { transform: scale(1.04); opacity: 1; }
|
||||
100% { transform: scale(1.00); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-grade {
|
||||
animation: grade-reveal 400ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes blur-pulse {
|
||||
0%, 100% { opacity: 0.65; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.animate-blur-pulse {
|
||||
animation: blur-pulse 3s ease-in-out 1;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer-loading {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent) 0%,
|
||||
var(--accent-light) 50%,
|
||||
var(--accent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes phosphor-pulse {
|
||||
0%, 100% { opacity: 0.6; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 400ms ease-out both;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 50ms; }
|
||||
.stagger-2 { animation-delay: 100ms; }
|
||||
.stagger-3 { animation-delay: 150ms; }
|
||||
.stagger-4 { animation-delay: 200ms; }
|
||||
.stagger-5 { animation-delay: 250ms; }
|
||||
.stagger-6 { animation-delay: 300ms; }
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.animate-ticker {
|
||||
animation: ticker-scroll 60s linear infinite;
|
||||
}
|
||||
|
||||
.animate-ticker:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Tier blur (the conversion engine)
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.tier-locked {
|
||||
position: relative;
|
||||
filter: blur(8px);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
animation: blur-pulse 3s ease-in-out 1;
|
||||
}
|
||||
|
||||
.tier-locked-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(10, 10, 15, 0.6) 60%, rgba(10, 10, 15, 0.85) 100%);
|
||||
border-radius: inherit;
|
||||
z-index: 2;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Utilities
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.text-balance { text-wrap: balance; }
|
||||
.text-pretty { text-wrap: pretty; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Texture layers — seasoning, not the meal
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.tex-scan {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.tex-scan::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255,255,255,var(--scan-opacity)) 0px,
|
||||
rgba(255,255,255,var(--scan-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
z-index: 2;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.tex-sweep {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.tex-sweep::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 140px;
|
||||
top: -140px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
rgba(0, 255, 184, var(--sweep-opacity)) 50%,
|
||||
transparent
|
||||
);
|
||||
z-index: 1;
|
||||
animation: crt-sweep 7s ease-in-out infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
@keyframes crt-sweep {
|
||||
0% { transform: translateY(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(120vh); opacity: 0; }
|
||||
}
|
||||
|
||||
.tex-vignette {
|
||||
position: relative;
|
||||
}
|
||||
.tex-vignette::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.tex-slash {
|
||||
position: relative;
|
||||
}
|
||||
.tex-slash::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
-2deg,
|
||||
transparent 0%,
|
||||
rgba(0, 212, 160, var(--slash-opacity)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tex-grain::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.9 0 0 0 0 0.9 0 0 0 0 1 0 0 0 0.45 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
opacity: var(--grain-opacity);
|
||||
z-index: 999;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Page-level CRT vignette, scales with --glitch */
|
||||
body.tex-grain::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse 110% 90% at 50% 50%, transparent 50%, rgba(0,0,0,calc(0.15 + 0.20 * var(--glitch))) 100%);
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
RGB split — broadcast misalignment effect
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.rgb-split {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.rgb-split::before,
|
||||
.rgb-split::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rgb-split::before {
|
||||
color: rgba(255, 60, 60, 0.55);
|
||||
transform: translateX(calc(-1 * var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.rgb-split::after {
|
||||
color: rgba(60, 120, 255, 0.55);
|
||||
transform: translateX(var(--rgb-shift));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Wordmark — VYNDR with RGB-split letters, green-glow R, blinking cursor
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.wordmark {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.10em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
.wm-letter {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.wm-letter::before,
|
||||
.wm-letter::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.wm-letter::before {
|
||||
color: rgba(255, 60, 60, 0.55);
|
||||
transform: translateX(calc(-1px - var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.wm-letter::after {
|
||||
color: rgba(60, 120, 255, 0.55);
|
||||
transform: translateX(calc(1px + var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.wordmark .vynd { color: var(--text-0); }
|
||||
.wordmark .r {
|
||||
color: var(--grade-a);
|
||||
text-shadow: 0 0 calc(var(--wm-size, 32px) * 0.25) rgba(0, 212, 160, 0.6);
|
||||
position: relative;
|
||||
}
|
||||
.wordmark .r::before { color: rgba(255, 60, 60, 0.45); }
|
||||
.wordmark .r::after { color: rgba(0, 255, 184, 0.60); }
|
||||
|
||||
.wm-cursor {
|
||||
display: inline-block;
|
||||
width: 0.16em;
|
||||
height: 0.9em;
|
||||
margin-left: 0.18em;
|
||||
background: var(--grade-a);
|
||||
box-shadow: 0 0 calc(var(--wm-size, 32px) * 0.3) rgba(0, 212, 160, 0.8);
|
||||
animation: cursor-blink 1.1s steps(2) infinite;
|
||||
}
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.wm-anim { animation: wm-tear 5.5s ease-in-out infinite; }
|
||||
@keyframes wm-tear {
|
||||
0%, 4%, 100% { transform: translateX(0); }
|
||||
5% { transform: translateX(2px) skewX(-2deg); filter: hue-rotate(20deg); }
|
||||
6% { transform: translateX(-1px); }
|
||||
7% { transform: translateX(0); filter: none; }
|
||||
47% { transform: translateX(0); }
|
||||
48% { transform: translateX(-2px) skewX(1deg); }
|
||||
49% { transform: translateX(1px); }
|
||||
50% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Glitch text — occasional horizontal tear, broadcast tinted ghosts
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.glitch-text {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.glitch-text::before,
|
||||
.glitch-text::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.glitch-text::before {
|
||||
color: rgba(255, 60, 60, 0.7);
|
||||
transform: translateX(calc(-1px - var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
animation: glitch-shift-r 4s steps(40) infinite;
|
||||
}
|
||||
.glitch-text::after {
|
||||
color: rgba(60, 180, 255, 0.7);
|
||||
transform: translateX(calc(1px + var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
animation: glitch-shift-b 4s steps(40) infinite reverse;
|
||||
}
|
||||
@keyframes glitch-shift-r {
|
||||
0%, 100% { clip-path: inset(0 0 0 0); transform: translateX(calc(-1px - var(--rgb-shift))); }
|
||||
92% { clip-path: inset(20% 0 60% 0); transform: translateX(-3px); }
|
||||
94% { clip-path: inset(60% 0 10% 0); transform: translateX(2px); }
|
||||
96% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
@keyframes glitch-shift-b {
|
||||
0%, 100% { clip-path: inset(0 0 0 0); }
|
||||
93% { clip-path: inset(45% 0 35% 0); transform: translateX(3px); }
|
||||
95% { clip-path: inset(10% 0 70% 0); transform: translateX(-2px); }
|
||||
97% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Transmission badge, frequency badge, phosphor underline
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.transmission {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--grade-a);
|
||||
background: rgba(0, 212, 160, 0.06);
|
||||
border: 1px solid rgba(0, 212, 160, 0.30);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.transmission .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--grade-a);
|
||||
box-shadow: 0 0 8px var(--grade-a);
|
||||
animation: pulse-live 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
.freq-badge {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.freq-badge .ch { color: var(--grade-a); font-weight: 700; }
|
||||
|
||||
.phosphor-line {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--grade-a), transparent);
|
||||
box-shadow: 0 0 8px rgba(0, 212, 160, 0.60);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The V watermark — Vendetta nod, hero background */
|
||||
.v-watermark {
|
||||
position: absolute;
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-weight: 800;
|
||||
font-size: 70vmin;
|
||||
line-height: 0.8;
|
||||
color: var(--acc-0);
|
||||
opacity: 0.06;
|
||||
letter-spacing: -0.05em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* CRT phosphor glow utility for grade letters */
|
||||
.grade-glow-aplus { color: var(--grade-aplus); text-shadow: 0 0 18px var(--grade-aplus), 0 0 36px rgba(0, 255, 184, 0.40); }
|
||||
.grade-glow-a { color: var(--grade-a); text-shadow: 0 0 14px rgba(0, 212, 160, 0.70), 0 0 30px rgba(0, 212, 160, 0.30); }
|
||||
.grade-glow-b { color: var(--grade-b); text-shadow: 0 0 12px rgba(74, 158, 255, 0.60), 0 0 24px rgba(74, 158, 255, 0.20); }
|
||||
.grade-glow-c { color: var(--grade-c); text-shadow: 0 0 12px rgba(255, 179, 71, 0.55); }
|
||||
.grade-glow-d { color: var(--grade-d); text-shadow: 0 0 12px rgba(255, 82, 82, 0.55); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type Signal = {
|
||||
id: string;
|
||||
ts: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
severity: 'critical' | 'notable' | 'info';
|
||||
type: 'evolution' | 'coaching' | 'cascade' | 'abs' | 'line_movement';
|
||||
title: string;
|
||||
detail: string;
|
||||
players?: string[];
|
||||
};
|
||||
|
||||
const SEVERITY: Record<Signal['severity'], { dot: string; label: string }> = {
|
||||
critical: { dot: '#FF4757', label: 'CRITICAL' },
|
||||
notable: { dot: '#FFB347', label: 'NOTABLE' },
|
||||
info: { dot: '#4A9EFF', label: 'INFO' },
|
||||
};
|
||||
|
||||
const SPORT_COLOR: Record<Signal['sport'], string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const [signals, setSignals] = useState<Signal[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/intelligence');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/intelligence/feed')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setSignals(Array.isArray(data?.signals) ? data.signals : []))
|
||||
.catch(() => setSignals([]));
|
||||
}, []);
|
||||
|
||||
const locked = tier !== 'desk';
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading intelligence feed…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 760, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
Intelligence feed
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Real-time signals the books wish you didn't see. Evolution. Coaching shifts. Cascade effects. Line movement.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{signals === null ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading signals…</p>
|
||||
) : signals.length === 0 ? (
|
||||
<div className="surface" style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
QUIET NIGHT
|
||||
</p>
|
||||
<p>No signals yet tonight. The engine is watching. When something moves, you'll see it here first.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ol style={{ position: 'relative', display: 'grid', gap: 12, listStyle: 'none', padding: 0 }} className={locked ? 'tier-locked' : ''}>
|
||||
{signals.map((s, idx) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${(idx % 6) + 1}`}
|
||||
style={{ padding: 18, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: SEVERITY[s.severity].dot,
|
||||
boxShadow: `0 0 0 4px ${SEVERITY[s.severity].dot}33`,
|
||||
}}
|
||||
aria-label={SEVERITY[s.severity].label}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[s.sport]}1F`,
|
||||
color: SPORT_COLOR[s.sport],
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{s.sport}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: SEVERITY[s.severity].dot, letterSpacing: '0.08em' }}>
|
||||
{s.type.toUpperCase().replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
{formatRelative(s.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>{s.title}</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>{s.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{locked && (
|
||||
<div
|
||||
className="tier-locked-overlay"
|
||||
style={{ minHeight: 240 }}
|
||||
>
|
||||
<p style={{ fontSize: 15, fontWeight: 600 }}>
|
||||
Real-time intelligence is a Desk feature.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', maxWidth: 360 }}>
|
||||
Evolution alerts. Coaching shifts. Cascade effects. ABS strike zone intel. Line movement signals across the slate.
|
||||
</p>
|
||||
<a href="/api/checkout?tier=desk" className="btn-primary">
|
||||
Go Desk — $44.99/mo
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (!Number.isFinite(diff)) return '—';
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
+92
-23
@@ -1,14 +1,83 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import PostHogProvider from '@/components/PostHogProvider';
|
||||
import AuthProvider from '@/contexts/AuthContext';
|
||||
import ParlayProvider from '@/contexts/ParlayContext';
|
||||
import ExplainModeProvider from '@/contexts/ExplainModeContext';
|
||||
import Nav from '@/components/Nav';
|
||||
import ParlayTray from '@/components/ParlayTray';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import InstallPrompt from '@/components/InstallPrompt';
|
||||
import PushPrompt from '@/components/PushPrompt';
|
||||
import MFAPrompt from '@/components/MFAPrompt';
|
||||
import MFAChallenge from '@/components/MFAChallenge';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BetonBLK — AI-Powered Parlay Intelligence',
|
||||
description: 'Stop guessing. Start grading. BetonBLK scans your parlay in seconds with AI-powered prop analysis across DraftKings, FanDuel, and BetMGM.',
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
|
||||
title: {
|
||||
default: 'VYNDR — Sports Prop Intelligence',
|
||||
template: '%s · VYNDR',
|
||||
},
|
||||
description:
|
||||
"Grade NBA, MLB, and WNBA props with intelligence the books don't want you to have. Built in Detroit.",
|
||||
applicationName: 'VYNDR',
|
||||
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
|
||||
manifest: '/manifest.json',
|
||||
keywords: [
|
||||
'sports prop grading',
|
||||
'NBA prop bet analysis',
|
||||
'MLB prop intelligence',
|
||||
'WNBA prop grading',
|
||||
'parlay correlation analysis',
|
||||
'prop betting tools',
|
||||
],
|
||||
openGraph: {
|
||||
title: 'BetonBLK — AI-Powered Parlay Intelligence',
|
||||
description: 'Stop guessing. Start grading.',
|
||||
title: "VYNDR — Intelligence the books don't want you to have",
|
||||
description:
|
||||
'Read player props with Bayesian intelligence. See the factors. Know the kill conditions. Take the edge back.',
|
||||
url: 'https://vyndr.app',
|
||||
siteName: 'VYNDR',
|
||||
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'VYNDR — Sports Prop Intelligence' }],
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'VYNDR',
|
||||
description: 'The books have every advantage. We built this to give it back.',
|
||||
images: ['/og-image.png'],
|
||||
creator: '@getvyndr',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/favicon.ico', sizes: '32x32' },
|
||||
{ url: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'VYNDR',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#06060B',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -18,27 +87,27 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen antialiased">
|
||||
<nav className="fixed top-0 w-full z-50 border-b border-[var(--border)] bg-[var(--bg)]/80 backdrop-blur-md">
|
||||
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<a href="/" className="font-mono font-bold text-xl tracking-tight">
|
||||
Beton<span className="text-[var(--accent)]">BLK</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<a href="/blog" className="text-[var(--text-muted)] hover:text-white transition">Blog</a>
|
||||
<a href="/scan" className="text-[var(--text-muted)] hover:text-white transition">Scan</a>
|
||||
<a href="/tracker" className="text-[var(--text-muted)] hover:text-white transition">Tracker</a>
|
||||
<a href="/login" className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white text-sm font-medium hover:opacity-90 transition">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="pt-16">{children}</main>
|
||||
<body className="antialiased tex-grain">
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
<ExplainModeProvider>
|
||||
<ParlayProvider>
|
||||
<Nav />
|
||||
<main style={{ paddingTop: 64, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||
<ParlayTray />
|
||||
<BottomTabBar />
|
||||
<InstallPrompt />
|
||||
<PushPrompt />
|
||||
<MFAPrompt />
|
||||
<MFAChallenge />
|
||||
</ParlayProvider>
|
||||
</ExplainModeProvider>
|
||||
</AuthProvider>
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
|
||||
type TierFilter = 'ALL' | 'A' | 'B' | 'C';
|
||||
|
||||
interface LedgerEntry {
|
||||
id: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Exclude<Sport, 'ALL'>;
|
||||
grade: string;
|
||||
projection?: number;
|
||||
actual?: number;
|
||||
hit: boolean | null;
|
||||
miss_reason?: string;
|
||||
graded_at: string;
|
||||
}
|
||||
|
||||
interface AccuracyBucket {
|
||||
tier: string;
|
||||
hits: number;
|
||||
losses: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Exclude<Sport, 'ALL'>, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function LedgerPage() {
|
||||
const [sport, setSport] = useState<Sport>('ALL');
|
||||
const [tier, setTier] = useState<TierFilter>('ALL');
|
||||
const [entries, setEntries] = useState<LedgerEntry[] | null>(null);
|
||||
const [accuracy, setAccuracy] = useState<AccuracyBucket[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (sport !== 'ALL') params.set('sport', sport);
|
||||
if (tier !== 'ALL') params.set('tier', tier);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/ledger?${params}`).then((r) => r.json()).catch(() => ({ entries: [] })),
|
||||
fetch('/api/ledger/accuracy').then((r) => r.json()).catch(() => ({ buckets: [] })),
|
||||
]).then(([entriesData, accuracyData]) => {
|
||||
setEntries(Array.isArray(entriesData?.entries) ? entriesData.entries : []);
|
||||
setAccuracy(Array.isArray(accuracyData?.buckets) ? accuracyData.buckets : []);
|
||||
});
|
||||
}, [sport, tier]);
|
||||
|
||||
const overall = useMemo(() => {
|
||||
if (!accuracy?.length) return null;
|
||||
const totals = accuracy.reduce(
|
||||
(acc, b) => ({ h: acc.h + b.hits, l: acc.l + b.losses }),
|
||||
{ h: 0, l: 0 },
|
||||
);
|
||||
const total = totals.h + totals.l;
|
||||
if (!total) return null;
|
||||
return { hits: totals.h, losses: totals.l, pct: Math.round((totals.h / total) * 100) };
|
||||
}, [accuracy]);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
|
||||
The Ledger.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 16 }}>
|
||||
Every grade. Every result. No hiding. No deleting.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Accuracy header strip */}
|
||||
{accuracy && accuracy.length > 0 && (
|
||||
<section
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{accuracy.map((b) => (
|
||||
<AccuracyTile key={b.tier} bucket={b} />
|
||||
))}
|
||||
{overall && (
|
||||
<AccuracyTile
|
||||
bucket={{ tier: 'Overall', hits: overall.hits, losses: overall.losses, pct: overall.pct }}
|
||||
highlight
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
|
||||
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{entries === null ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
No grades yet.
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Read your first prop to start building your Ledger. Every grade you run shows up here, with the result.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
||||
Read a Prop →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
|
||||
style={{ padding: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[entry.sport]}1F`,
|
||||
color: SPORT_COLOR[entry.sport],
|
||||
}}
|
||||
>
|
||||
{entry.sport}
|
||||
</span>
|
||||
<GradePill grade={entry.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{entry.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 12 }}>
|
||||
{entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{entry.actual != null ? `Actual ${entry.actual}` : 'Pending'}
|
||||
</span>
|
||||
{entry.hit !== null && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: entry.hit ? 'var(--grade-a)' : 'var(--grade-d)',
|
||||
}}
|
||||
>
|
||||
{entry.hit ? 'HIT' : 'MISS'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.hit === false && entry.miss_reason && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--grade-d)',
|
||||
background: 'rgba(255,107,107,0.10)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,107,107,0.30)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontWeight: 700 }}>Why we missed:</strong> {entry.miss_reason}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccuracyTile({ bucket, highlight }: { bucket: AccuracyBucket; highlight?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
background: highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
border: highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{bucket.tier.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 800, color: highlight ? 'var(--grade-a)' : 'var(--text-primary)', marginTop: 4 }}>
|
||||
{bucket.pct}%
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||
{bucket.hits}-{bucket.losses}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginRight: 12 }}>
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
{options.map((o) => {
|
||||
const active = o === value;
|
||||
return (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => onChange(o)}
|
||||
className={active ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+108
-27
@@ -1,61 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackLogin } from '@/lib/analytics';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
function LoginInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') || '/dashboard';
|
||||
const { signIn, signInWithGoogle } = useAuth();
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
// TODO: Integrate with Supabase Auth
|
||||
// const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
setLoading(false);
|
||||
setError('Auth integration pending. Backend is ready.');
|
||||
const { error: err } = await signIn(email, password);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
trackLogin({ method: 'password' });
|
||||
router.replace(next);
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy(true);
|
||||
await signInWithGoogle();
|
||||
// Supabase redirects to provider; on return AuthContext picks up the session.
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-center mb-8">Log In</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Log in</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
|
||||
Welcome back. Let's read something.
|
||||
</p>
|
||||
|
||||
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div style={dividerStyle}>
|
||||
<span style={dividerLine} />
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>OR</span>
|
||||
<span style={dividerLine} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Email</label>
|
||||
<label className="mono" style={labelStyle}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Password</label>
|
||||
<label className="mono" style={labelStyle}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="input-field"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<a href="/forgot-password" style={{ display: 'inline-block', marginTop: 8, fontSize: 12, color: 'var(--text-secondary)', textDecoration: 'none' }}>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{error && <p className="text-[var(--grade-d)] text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-[var(--text-muted)] mt-6">
|
||||
Don't have an account? <a href="/signup" className="text-[var(--accent)] hover:underline">Sign up</a>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-secondary)', marginTop: 20 }}>
|
||||
No account?{' '}
|
||||
<a href={`/signup${next ? `?next=${encodeURIComponent(next)}` : ''}`} style={{ color: 'var(--grade-a)' }}>
|
||||
Sign up free
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const dividerLine: React.CSSProperties = {
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
list: 'api',
|
||||
title: 'API access',
|
||||
body: 'Programmatic access to the grading engine. Build your own dashboards, alerts, automations.',
|
||||
},
|
||||
{
|
||||
list: 'alerts',
|
||||
title: 'Custom alerts',
|
||||
body: 'Get pinged when specific players, stats, or matchups hit your threshold — by SMS, email, or webhook.',
|
||||
},
|
||||
{
|
||||
list: 'The Edge',
|
||||
title: 'The Edge — playbook',
|
||||
body: 'Long-form analysis of the model and how to deploy it in real bankroll management.',
|
||||
},
|
||||
{
|
||||
list: 'merch',
|
||||
title: 'Capsule drop',
|
||||
body: 'First merch run. 200 units. Founder access gets first pull.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [submitted, setSubmitted] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const joinList = async (list: string) => {
|
||||
if (honeypot) return;
|
||||
setError('');
|
||||
if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||
setError('Enter a valid email first.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, list }),
|
||||
});
|
||||
if (res.ok) setSubmitted((s) => new Set(s).add(list));
|
||||
} catch {
|
||||
setSubmitted((s) => new Set(s).add(list));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 880, margin: '0 auto', padding: '64px 16px 120px' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 12 }}>
|
||||
COMING SOON
|
||||
</p>
|
||||
<h1 style={{ fontSize: 'clamp(28px,4vw,40px)', fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 12 }}>
|
||||
The VYNDR Marketplace.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 16, maxWidth: 560, margin: '0 auto' }}>
|
||||
API access, custom alerts, deep analysis, and limited drops. Reserve your spot — these go to founder users first.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{ITEMS.map((item) => (
|
||||
<article key={item.list} className="surface diagonal-cut surface-hover" style={{ padding: 24 }}>
|
||||
<h3 style={{ fontSize: 17, fontWeight: 700, marginBottom: 8 }}>{item.title}</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{item.body}
|
||||
</p>
|
||||
{submitted.has(item.list) ? (
|
||||
<p className="mono" style={{ color: 'var(--grade-a)', fontSize: 13 }}>✓ On the list.</p>
|
||||
) : (
|
||||
<button onClick={() => joinList(item.list)} className="btn-ghost" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
Join waitlist
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="surface diagonal-cut" style={{ padding: 24, maxWidth: 460, margin: '0 auto' }}>
|
||||
<label className="mono" style={{ display: 'block', fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
YOUR EMAIL
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="you@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
{/* Honeypot — hidden from humans */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0 }}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden
|
||||
/>
|
||||
{error && <p style={{ fontSize: 12, color: 'var(--grade-d)', marginTop: 8 }}>{error}</p>}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12 }}>
|
||||
Tap any "Join waitlist" above and we'll add this email to that list. No spam. Cancel anytime.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from 'next/link';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
export const metadata = {
|
||||
title: '404 — Signal Lost',
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className="tex-scan"
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 144px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Wordmark size={28} />
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>TRANSMISSION INTERRUPTED</p>
|
||||
<h1
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 'clamp(80px, 16vw, 160px)',
|
||||
fontWeight: 800,
|
||||
color: 'var(--grade-c)',
|
||||
textShadow: '0 0 24px rgba(255, 179, 71, 0.6), 0 0 48px rgba(255, 179, 71, 0.25)',
|
||||
lineHeight: 0.9,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 480 }}>
|
||||
This page doesn't exist. The signal was lost.
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 480 }}>
|
||||
Check the URL, head back to the slate, or open the Ledger to review past grades.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<Link href="/dashboard" className="btn-primary">Back to Dashboard →</Link>
|
||||
<Link href="/ledger" className="btn-ghost">Open the Ledger</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+30
-2
@@ -1,16 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
import HowItWorks from '@/components/HowItWorks';
|
||||
import LivePropsStrip from '@/components/LivePropsStrip';
|
||||
import Features from '@/components/Features';
|
||||
import HowItWorks from '@/components/HowItWorks';
|
||||
import Pricing from '@/components/Pricing';
|
||||
import FAQ from '@/components/FAQ';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export default function Home() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) router.replace('/dashboard');
|
||||
}, [user, loading, router]);
|
||||
|
||||
// While we know the user is signed in we suppress the marketing
|
||||
// render to avoid a flicker before the redirect lands.
|
||||
if (loading || user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Loading the slate
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<LivePropsStrip />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy',
|
||||
description: 'How VYNDR handles your data. Plain English.',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
|
||||
{
|
||||
title: 'We never sell your personal data.',
|
||||
body: [
|
||||
'VYNDR Intelligence LLC does not sell, rent, or trade your personal data to anyone. Ever. Our business is a subscription to an analytics product — not a data resale operation.',
|
||||
],
|
||||
emphasized: true,
|
||||
},
|
||||
{
|
||||
title: 'Data we collect',
|
||||
body: [
|
||||
'Account data: email, password hash, age confirmation, signup timestamp.',
|
||||
'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.',
|
||||
'Payment data: NexaPay processes all card data — we never see or store your card. We retain a NexaPay customer ID and your subscription status.',
|
||||
'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'How we use your data',
|
||||
body: [
|
||||
'To provide the service: sign you in, gate reads by tier, deliver grades.',
|
||||
'To improve the model: aggregate, anonymized read data helps calibration. Individual reads are never used to identify you to third parties.',
|
||||
'Aggregate trend features: "Most Read Tonight" and "Most Parlayed Tonight" use anonymized counts across all users. Your individual reads are not exposed.',
|
||||
'To communicate: account confirmation, payment receipts, renewal reminders, critical service notices.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'What we do not do',
|
||||
body: [
|
||||
'We do not sell your personal data. Ever.',
|
||||
'We do not share your individual betting activity with sportsbooks, advertisers, or data brokers.',
|
||||
'We do not run ads. The business model is subscription, period.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Data retention',
|
||||
body: [
|
||||
'Account data: retained while your account is active. Deleted on request.',
|
||||
'Read history: retained indefinitely (anonymized after account deletion) for model calibration.',
|
||||
'Payment records: retained for 7 years per US tax and accounting requirements.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Cookies and analytics',
|
||||
body: [
|
||||
'Essential cookies: auth session, read counter (cannot be disabled — the service does not work without them).',
|
||||
'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
body: [
|
||||
'We send push notifications and emails based on your preferences in Settings. You can disable any notification type at any time. Transactional messages (receipts, password resets, security alerts) cannot be disabled while your account is active.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Your rights',
|
||||
body: [
|
||||
'Access: request a copy of your data at any time.',
|
||||
'Deletion: request account and data deletion at privacy@vyndr.app — we comply within 30 days.',
|
||||
'Correction: update your email and other profile data from the profile page.',
|
||||
'Export: request a JSON export of your read history.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Children',
|
||||
body: [
|
||||
'VYNDR is not for anyone under 21. We do not knowingly collect data from minors. If we discover an account belongs to a minor, we will delete it immediately.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Changes',
|
||||
body: [
|
||||
'If we make material changes to this policy, we will notify you by email at least 30 days before they take effect.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
body: [
|
||||
'Privacy questions or data requests: privacy@vyndr.app',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 720, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>Privacy Policy</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
EFFECTIVE: MAY 2026
|
||||
</p>
|
||||
</header>
|
||||
{SECTIONS.map((s) => (
|
||||
<section
|
||||
key={s.title}
|
||||
style={{
|
||||
marginBottom: 32,
|
||||
...(s.emphasized
|
||||
? {
|
||||
borderLeft: '3px solid var(--grade-a)',
|
||||
background: 'rgba(0, 212, 160, 0.04)',
|
||||
borderRadius: 4,
|
||||
padding: '12px 16px',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: s.emphasized ? 20 : 18, fontWeight: 700, marginBottom: 12 }}>{s.title}</h2>
|
||||
{s.body.map((p, i) => (
|
||||
<p
|
||||
key={i}
|
||||
style={{
|
||||
color: s.emphasized ? 'var(--text-0)' : 'var(--text-secondary)',
|
||||
fontSize: s.emphasized ? 16 : 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface FullProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
tier: 'free' | 'analyst' | 'desk';
|
||||
scan_count: number;
|
||||
scan_reset_date: string;
|
||||
subscription_status: string;
|
||||
subscription_end: string | null;
|
||||
founder_pricing: boolean;
|
||||
cancel_at_period_end: boolean;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { user, signOut, loading: authLoading } = useAuth();
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/profile');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
fetch('/api/user/profile', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(setProfile)
|
||||
.catch(() => setProfile(null));
|
||||
}, [user]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Cancel your subscription at the end of the current period?')) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ cancel_at_period_end: true }),
|
||||
});
|
||||
setWorking(false);
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setProfile((p) => (p ? { ...p, ...updated } : p));
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.error || 'Could not update — try again in a moment.');
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || !user || !profile) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading profile…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 4 }}>
|
||||
Profile
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
{profile.email}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tier card */}
|
||||
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 24, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<div>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>YOUR TIER</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier) }}>
|
||||
{profile.tier}
|
||||
{profile.founder_pricing && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--text-primary)',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
FOUNDER
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{profile.tier === 'free' && (
|
||||
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.tier !== 'free' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
||||
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.tier === 'free' && (
|
||||
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Founder pricing promo for free users */}
|
||||
{profile.tier === 'free' && (
|
||||
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
FOUNDER ACCESS
|
||||
</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>
|
||||
Lock $14.99/mo for life before it's gone.
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
First 100 users keep founder pricing even after the regular price jumps to $24.99.
|
||||
</p>
|
||||
<a href="/api/checkout?tier=analyst" className="btn-primary">
|
||||
Lock founder price →
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Subscription actions */}
|
||||
{profile.tier !== 'free' && !profile.cancel_at_period_end && (
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
Your access continues through the end of the current billing period. No refunds for partial months.
|
||||
</p>
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<button onClick={handleCancel} disabled={working} className="btn-ghost" style={{ color: 'var(--grade-d)' }}>
|
||||
{working ? 'Working…' : 'Cancel at period end'}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{profile.cancel_at_period_end && (
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
||||
Cancellation scheduled. Access ends{' '}
|
||||
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Sign out */}
|
||||
<section style={{ marginTop: 32, textAlign: 'center' }}>
|
||||
<button onClick={() => signOut().then(() => router.replace('/'))} className="btn-ghost" style={{ padding: '12px 24px' }}>
|
||||
Sign out
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'warn' }) {
|
||||
const color = tone === 'good' ? 'var(--grade-a)' : tone === 'warn' ? 'var(--grade-c)' : 'var(--text-primary)';
|
||||
return (
|
||||
<div style={{ padding: '12px 14px', background: 'var(--bg-elevated)', borderRadius: 10 }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color, marginTop: 4, textTransform: 'capitalize' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tierColor(tier: string): string {
|
||||
if (tier === 'desk') return 'var(--grade-a)';
|
||||
if (tier === 'analyst') return 'var(--grade-b)';
|
||||
return 'var(--text-primary)';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Responsible Gambling',
|
||||
description: 'VYNDR provides analytics, not bets. If gambling stops being fun, stop.',
|
||||
};
|
||||
|
||||
export default function ResponsibleGamblingPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 680, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>
|
||||
If gambling stops being fun, stop.
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
RESPONSIBLE GAMBLING
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 16, lineHeight: 1.7 }}>
|
||||
<p style={{ marginBottom: 16 }}>
|
||||
VYNDR provides sports analysis for informational purposes only. We do not take bets, facilitate wagers, or encourage gambling. We exist to help bettors make more informed decisions — including the decision to walk away.
|
||||
</p>
|
||||
<p style={{ marginBottom: 16 }}>
|
||||
Sports betting is entertainment. It should never feel like a job, a financial plan, or an escape. If you find yourself chasing losses, hiding bets from people you love, or betting more than you can afford to lose, those are warning signs that gambling has stopped being fun.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="surface diagonal-cut" style={{ padding: 24, marginTop: 32, borderColor: 'var(--grade-a)' }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Get help</h2>
|
||||
<ul style={{ display: 'grid', gap: 12, fontSize: 15 }}>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
|
||||
<br />
|
||||
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
1-800-522-4700
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>24/7, free, confidential.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
|
||||
ncpgambling.org
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Resources, support groups, self-assessment tools.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.gamblersanonymous.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
|
||||
gamblersanonymous.org
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Local meetings nationwide.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 32 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Self-exclusion</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
|
||||
Most US sportsbooks support a "self-exclusion" registry. You can voluntarily ban yourself from accessing sportsbook accounts for a period of months or years. Your state's gaming control board can help — search "[your state] gaming control board self-exclusion" to start.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 32 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Our position</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
|
||||
We built VYNDR because we believe sports bettors deserve better tools than the books give them. We do not believe gambling makes anyone's life better when it stops being fun. If you need to walk away from sports betting entirely, that's the right call. Cancel your subscription, delete the app, and reach out to one of the resources above.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+566
-249
@@ -1,292 +1,609 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import GradeCard from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import {
|
||||
trackScanCompleted,
|
||||
trackScanLimitHit,
|
||||
trackUpgradeClicked,
|
||||
} from '@/lib/analytics';
|
||||
|
||||
interface Leg {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number | string;
|
||||
direction: string;
|
||||
book: string;
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
}
|
||||
|
||||
interface LegResult {
|
||||
index: number;
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: string;
|
||||
interface Player {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
interface ScanResponse {
|
||||
grade: string;
|
||||
confidence: number;
|
||||
edge_pct: number;
|
||||
kill_conditions: { code: string; reason: string }[];
|
||||
reasoning_summary: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
scan_id: string;
|
||||
parlay_grade: string;
|
||||
parlay_confidence: number;
|
||||
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
|
||||
legs: LegResult[];
|
||||
scan_count: number;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: Record<string, string>;
|
||||
alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number }[];
|
||||
kill_conditions?: { code: string; reason: string }[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
scans_remaining: number | null;
|
||||
upgrade_pitch: {
|
||||
hook: string;
|
||||
insight: string;
|
||||
cta: string;
|
||||
tier_recommended: string;
|
||||
founder_price: string;
|
||||
} | null;
|
||||
tier: 'free' | 'analyst' | 'desk';
|
||||
error?: string;
|
||||
upgrade?: { tier: string; price: number };
|
||||
}
|
||||
|
||||
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
|
||||
const BOOKS = ['draftkings', 'fanduel', 'betmgm'];
|
||||
const NBA_STATS = [
|
||||
{ id: 'points', label: 'Points' },
|
||||
{ id: 'rebounds', label: 'Rebounds' },
|
||||
{ id: 'assists', label: 'Assists' },
|
||||
{ id: 'threes', label: '3-Pointers' },
|
||||
{ id: 'steals', label: 'Steals' },
|
||||
{ id: 'blocks', label: 'Blocks' },
|
||||
{ id: 'pra', label: 'P+R+A' },
|
||||
{ id: 'turnovers', label: 'Turnovers' },
|
||||
];
|
||||
|
||||
const emptyLeg: Leg = { player: '', stat_type: 'points', line: '', direction: 'over', book: 'draftkings' };
|
||||
const MLB_STATS = [
|
||||
{ id: 'strikeouts', label: 'Strikeouts (P)' },
|
||||
{ id: 'hits_allowed', label: 'Hits Allowed (P)' },
|
||||
{ id: 'earned_runs', label: 'Earned Runs (P)' },
|
||||
{ id: 'innings_pitched', label: 'Innings Pitched (P)' },
|
||||
{ id: 'hits', label: 'Hits' },
|
||||
{ id: 'total_bases', label: 'Total Bases' },
|
||||
{ id: 'rbi', label: 'RBI' },
|
||||
{ id: 'runs', label: 'Runs' },
|
||||
{ id: 'home_runs', label: 'Home Runs' },
|
||||
];
|
||||
|
||||
const WNBA_STATS = NBA_STATS;
|
||||
|
||||
const SPORT_STATS: Record<Sport, { id: string; label: string }[]> = {
|
||||
NBA: NBA_STATS,
|
||||
MLB: MLB_STATS,
|
||||
WNBA: WNBA_STATS,
|
||||
};
|
||||
|
||||
const SPORT_ACCENT: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
const [legs, setLegs] = useState<Leg[]>([{ ...emptyLeg }]);
|
||||
const [results, setResults] = useState<ScanResult | null>(null);
|
||||
const router = useRouter();
|
||||
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
|
||||
const { addLeg, legCount, open } = useParlay();
|
||||
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [gameId, setGameId] = useState<string>('');
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
|
||||
const [stat, setStat] = useState<string>('points');
|
||||
const [line, setLine] = useState<string>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<ScanResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<string[]>([]);
|
||||
const [activeInput, setActiveInput] = useState(-1);
|
||||
|
||||
const updateLeg = (index: number, field: keyof Leg, value: string | number) => {
|
||||
setLegs((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
|
||||
};
|
||||
// Auth gate — push anonymous users to signup
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/signup?next=/scan');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const addLeg = () => {
|
||||
if (legs.length < 12) setLegs([...legs, { ...emptyLeg }]);
|
||||
};
|
||||
// Reset stat selection when sport changes
|
||||
useEffect(() => {
|
||||
const list = SPORT_STATS[sport];
|
||||
if (!list.some((s) => s.id === stat)) setStat(list[0].id);
|
||||
}, [sport, stat]);
|
||||
|
||||
const removeLeg = (index: number) => {
|
||||
if (legs.length > 1) setLegs(legs.filter((_, i) => i !== index));
|
||||
};
|
||||
// Load tonight's slate
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setGames(null);
|
||||
setGameId('');
|
||||
fetch(`/api/games/tonight?sport=${sport}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { games: Game[] }) => {
|
||||
if (!cancelled) setGames(Array.isArray(data?.games) ? data.games : []);
|
||||
})
|
||||
.catch(() => !cancelled && setGames([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
const searchPlayer = useCallback(async (name: string, index: number) => {
|
||||
if (name.length < 2) { setPlayerSuggestions([]); return; }
|
||||
setActiveInput(index);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000'}/players/search?name=${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
setPlayerSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
|
||||
} catch { setPlayerSuggestions([]); }
|
||||
}, []);
|
||||
// Debounced player search — narrow to selected game when set
|
||||
const searchPlayers = useCallback(
|
||||
async (query: string) => {
|
||||
if (query.trim().length < 2) {
|
||||
setPlayerSuggestions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({ sport, q: query });
|
||||
if (gameId) params.set('game_id', gameId);
|
||||
const res = await fetch(`/api/players/search?${params}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { players: Player[] };
|
||||
setPlayerSuggestions((data.players || []).slice(0, 8));
|
||||
} catch {
|
||||
setPlayerSuggestions([]);
|
||||
}
|
||||
},
|
||||
[sport, gameId],
|
||||
);
|
||||
|
||||
const scan = async () => {
|
||||
const validLegs = legs.filter((l) => l.player && l.line);
|
||||
if (validLegs.length < 2) { setError('Add at least 2 legs'); return; }
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => void searchPlayers(playerQuery), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [playerQuery, searchPlayers]);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => selectedPlayer && stat && line !== '' && !scanning && canScan,
|
||||
[selectedPlayer, stat, line, scanning, canScan],
|
||||
);
|
||||
|
||||
const runScan = async () => {
|
||||
if (!canSubmit) {
|
||||
if (!canScan) {
|
||||
trackScanLimitHit({ current_scan_count: 5, tier });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setResults(null);
|
||||
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/scan/parlay`, {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(typeof window !== 'undefined' && localStorage.getItem('sb-token')
|
||||
? { Authorization: `Bearer ${localStorage.getItem('sb-token')}` }
|
||||
: {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: validLegs.map((l) => ({ ...l, line: Number(l.line) })) }),
|
||||
body: JSON.stringify({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
direction,
|
||||
book: 'draftkings',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Scan failed');
|
||||
setResults(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
const data = (await res.json()) as ScanResponse;
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'The engine hit a wall. Try that read again.');
|
||||
if (res.status === 402) trackScanLimitHit({ current_scan_count: 5, tier });
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
bumpScanCount();
|
||||
trackScanCompleted({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
grade: data.grade,
|
||||
tier,
|
||||
});
|
||||
} catch {
|
||||
setError('The engine hit a wall. Try that read again.');
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setResult(null);
|
||||
setError('');
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setLine('');
|
||||
};
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-8 px-4 max-w-3xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Scan Parlay</h1>
|
||||
{results && results.scans_remaining != null && (
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
<span className="font-mono">{results.scan_count}</span> of 5 scans used
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section
|
||||
className="diagonal-cut animate-fade-up"
|
||||
style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px', position: 'relative' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
Grade a prop.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Pick a sport, find the player, set the line. We grade it in seconds.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Leg Builder */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{legs.map((leg, i) => (
|
||||
<div key={i} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">Leg {i + 1}</span>
|
||||
{legs.length > 1 && (
|
||||
<button onClick={() => removeLeg(i)} className="text-xs text-[var(--grade-d)] hover:text-white">Remove</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="col-span-2 relative">
|
||||
<input
|
||||
placeholder="Player name"
|
||||
value={leg.player}
|
||||
onChange={(e) => {
|
||||
updateLeg(i, 'player', e.target.value);
|
||||
searchPlayer(e.target.value, i);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setPlayerSuggestions([]), 200)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
{activeInput === i && playerSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
{playerSuggestions.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onMouseDown={() => { updateLeg(i, 'player', name); setPlayerSuggestions([]); }}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--border)] transition"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={leg.stat_type}
|
||||
onChange={(e) => updateLeg(i, 'stat_type', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="Line"
|
||||
value={leg.line}
|
||||
onChange={(e) => updateLeg(i, 'line', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
<select
|
||||
value={leg.direction}
|
||||
onChange={(e) => updateLeg(i, 'direction', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
<option value="over">Over</option>
|
||||
<option value="under">Under</option>
|
||||
</select>
|
||||
<select
|
||||
value={leg.book}
|
||||
onChange={(e) => updateLeg(i, 'book', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-8">
|
||||
{legs.length < 12 && (
|
||||
<button onClick={addLeg} className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition">
|
||||
+ Add Leg
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={scanning || legs.filter((l) => l.player && l.line).length < 2}
|
||||
className="flex-1 py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-40"
|
||||
{/* Scan counter */}
|
||||
{tier === 'free' && scansRemaining != null && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{scanning ? 'Scanning...' : 'Scan Parlay'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-4 mb-6 rounded-xl bg-[var(--grade-d)]/10 border border-[var(--grade-d)] text-[var(--grade-d)] text-sm">{error}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Grade */}
|
||||
<div className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<p className="text-sm text-[var(--text-muted)] mb-3">Parlay Grade</p>
|
||||
<div className="flex justify-center mb-3">
|
||||
<GradeCard grade={results.parlay_grade} confidence={results.parlay_confidence} />
|
||||
</div>
|
||||
{results.correlation_flags.length > 0 && (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{results.correlation_flags.length} correlation flag{results.correlation_flags.length > 1 ? 's' : ''} detected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Individual Legs */}
|
||||
{results.legs.map((leg) => (
|
||||
<div key={leg.index} className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{leg.player}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{leg.direction.charAt(0).toUpperCase() + leg.direction.slice(1)} {leg.line} {leg.stat_type}
|
||||
</p>
|
||||
</div>
|
||||
<GradeCard grade={leg.grade} confidence={leg.confidence} />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-2">{leg.reasoning_summary}</p>
|
||||
{leg.edge_pct !== 0 && (
|
||||
<span className={`text-xs font-mono ${leg.edge_pct > 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
|
||||
Edge: {leg.edge_pct > 0 ? '+' : ''}{leg.edge_pct}%
|
||||
</span>
|
||||
)}
|
||||
{leg.kill_conditions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{leg.kill_conditions.map((k) => (
|
||||
<span key={k.code} className="px-2 py-0.5 text-xs bg-[var(--grade-d)]/10 text-[var(--grade-d)] rounded">
|
||||
{k.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Correlations */}
|
||||
{results.correlation_flags.length > 0 && (
|
||||
<div className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<h3 className="font-semibold mb-3">Correlations</h3>
|
||||
{results.correlation_flags.map((flag, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm mb-2">
|
||||
<span className={flag.impact === 'major_negative' ? 'text-[var(--grade-d)]' : flag.impact === 'positive' ? 'text-[var(--grade-a)]' : 'text-[var(--grade-c)]'}>
|
||||
{flag.impact === 'major_negative' ? '!!' : flag.impact === 'positive' ? '+' : '!'}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">{flag.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Pitch */}
|
||||
{results.upgrade_pitch && (
|
||||
<div className="p-6 rounded-2xl bg-[var(--accent)]/10 border border-[var(--accent)]">
|
||||
<p className="font-semibold mb-2">{results.upgrade_pitch.hook}</p>
|
||||
<p className="text-sm text-[var(--text-muted)] mb-4">{results.upgrade_pitch.insight}</p>
|
||||
<a href="#pricing" className="inline-block px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
|
||||
{results.upgrade_pitch.cta}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setResults(null); setLegs([{ ...emptyLeg }]); }}
|
||||
className="flex-1 py-3 border border-[var(--border)] rounded-xl text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
New Scan
|
||||
</button>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', letterSpacing: '0.05em' }}>
|
||||
{scansRemaining} OF 5 FREE READS REMAINING THIS MONTH
|
||||
</span>
|
||||
<div style={{ flex: 1, maxWidth: 160, height: 4, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${(scansRemaining / 5) * 100}%`,
|
||||
height: '100%',
|
||||
background: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--grade-a)',
|
||||
transition: 'width 200ms ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport tabs */}
|
||||
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)' }}>
|
||||
{(Object.keys(SPORT_STATS) as Sport[]).map((s) => {
|
||||
const active = s === sport;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setSport(s)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${active ? SPORT_ACCENT[s] : 'transparent'}`,
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
transition: 'color 200ms ease',
|
||||
marginBottom: -1,
|
||||
boxShadow: active ? `0 4px 16px ${SPORT_ACCENT[s]}26` : 'none',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Game selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="mono" style={labelStyle}>Tonight's slate</label>
|
||||
{games === null ? (
|
||||
<div style={shimmerStyle} />
|
||||
) : games.length === 0 ? (
|
||||
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
No games posted yet. Books usually open player props 2–3 hours before tip.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={gameId}
|
||||
onChange={(e) => setGameId(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Game"
|
||||
>
|
||||
<option value="">All games</option>
|
||||
{games.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.away} @ {g.home} · {formatTime(g.start_time)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Player search */}
|
||||
<div style={{ marginBottom: 16, position: 'relative' }}>
|
||||
<label className="mono" style={labelStyle}>Player</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder={`Search ${sport} players`}
|
||||
value={playerQuery}
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
||||
<div
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: 4,
|
||||
zIndex: 20,
|
||||
padding: 4,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{playerSuggestions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedPlayer(p.full_name);
|
||||
setPlayerQuery(p.full_name);
|
||||
setPlayerSuggestions([]);
|
||||
}}
|
||||
style={suggestionStyle}
|
||||
>
|
||||
<span>{p.full_name}</span>
|
||||
{p.team && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
{p.team}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat + line + direction */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr 0.8fr', gap: 12, marginBottom: 24 }}>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Stat</label>
|
||||
<select className="input-field" value={stat} onChange={(e) => setStat(e.target.value)}>
|
||||
{SPORT_STATS[sport].map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Line</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
inputMode="decimal"
|
||||
className="input-field"
|
||||
placeholder="0.0"
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Side</label>
|
||||
<div style={{ display: 'flex', borderRadius: 12, overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
{(['over', 'under'] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDirection(d)}
|
||||
aria-pressed={direction === d}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 0',
|
||||
background: direction === d ? 'var(--accent)' : 'transparent',
|
||||
color: direction === d ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
textTransform: 'capitalize',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade button OR upgrade trigger */}
|
||||
{!canScan ? (
|
||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 32, textAlign: 'center', maxWidth: 440, margin: '0 auto' }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)', marginBottom: 12 }}>SIGNAL EXHAUSTED</p>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
||||
You've used your 5 free reads this month.
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 20 }}>
|
||||
Unlock unlimited reads — plus kill conditions, alt lines, and the full intelligence layer.
|
||||
</p>
|
||||
<p className="num" style={{
|
||||
fontSize: 32, color: 'var(--grade-a)', marginBottom: 4,
|
||||
textShadow: '0 0 14px rgba(0, 212, 160, 0.7)',
|
||||
}}>
|
||||
$14.99<span style={{ fontSize: 14, color: 'var(--text-1)' }}>/mo</span>
|
||||
</p>
|
||||
<p style={{ color: 'var(--grade-c)', fontSize: 13, fontWeight: 600, marginBottom: 20 }}>
|
||||
Locked for life. This rate disappears June 15.
|
||||
</p>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', padding: 14 }}
|
||||
onClick={() => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'scan_limit' });
|
||||
router.push('/api/checkout?tier=analyst');
|
||||
}}
|
||||
>
|
||||
Upgrade Now
|
||||
</button>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 12 }}>
|
||||
Or come back next month for 5 more free reads.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={runScan}
|
||||
disabled={!canSubmit}
|
||||
className={scanning ? 'shimmer-loading' : 'btn-primary'}
|
||||
style={{ width: '100%', padding: 16, fontSize: 15, color: 'var(--text-primary)', border: 'none', borderRadius: 12, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed', opacity: canSubmit ? 1 : 0.4 }}
|
||||
>
|
||||
{scanning ? 'Running the model…' : 'Read It'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,107,107,0.10)',
|
||||
border: '1px solid rgba(255,107,107,0.30)',
|
||||
color: 'var(--grade-d)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grade card output */}
|
||||
{result && (
|
||||
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
|
||||
<GradeCard
|
||||
sport={sport}
|
||||
player={selectedPlayer}
|
||||
stat={stat}
|
||||
line={Number(line)}
|
||||
direction={direction}
|
||||
grade={result.grade}
|
||||
projection={result.projection}
|
||||
confidence={result.confidence}
|
||||
sample_size={result.sample_size}
|
||||
factors={result.factors}
|
||||
alt_lines={result.alt_lines}
|
||||
kill_conditions={result.kill_conditions}
|
||||
reasoning={result.reasoning}
|
||||
historical_hit_rate={result.historical_hit_rate}
|
||||
tier={tier}
|
||||
onUpgradeClick={(target, from) => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from });
|
||||
router.push(`/api/checkout?tier=${target}`);
|
||||
}}
|
||||
onAddToParlay={() => {
|
||||
addLeg({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
direction,
|
||||
grade: result.grade,
|
||||
confidence: result.confidence ?? 50,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button onClick={reset} className="btn-ghost" style={{ flex: 1 }}>
|
||||
Read another prop
|
||||
</button>
|
||||
<a href="/dashboard" className="btn-ghost" style={{ flex: 1 }}>
|
||||
Back to slate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Helpful sticky parlay indicator */}
|
||||
{legCount > 0 && (
|
||||
<button
|
||||
onClick={open}
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 88,
|
||||
right: 16,
|
||||
zIndex: 30,
|
||||
padding: '10px 16px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent)',
|
||||
border: '1px solid var(--accent-light)',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
boxShadow: '0 8px 24px var(--accent-glow)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Parlay · {legCount} leg{legCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const shimmerStyle: React.CSSProperties = {
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background:
|
||||
'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
};
|
||||
|
||||
const suggestionStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import ExplainModeToggle from '@/components/ExplainModeToggle';
|
||||
|
||||
// MFA settings page. Gated to paid tiers (analyst | desk) — free users get
|
||||
// redirected to /pricing. We don't *force* MFA, only strongly encourage it,
|
||||
// because we don't want to lock paying users out of an account they own.
|
||||
|
||||
type EnrollState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'enrolling'; factorId: string; qr: string; secret: string }
|
||||
| { status: 'enrolled' }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const [hasMFA, setHasMFA] = useState<boolean | null>(null);
|
||||
const [enrollState, setEnrollState] = useState<EnrollState>({ status: 'idle' });
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const refreshFactors = useCallback(async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
if (error) return;
|
||||
const verified = (data?.totp ?? []).some((f) => f.status === 'verified');
|
||||
setHasMFA(verified);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
router.replace('/login?next=/settings/security');
|
||||
return;
|
||||
}
|
||||
if (tier === 'free') {
|
||||
router.replace('/upgrade/desk');
|
||||
return;
|
||||
}
|
||||
void refreshFactors();
|
||||
}, [authLoading, user, tier, router, refreshFactors]);
|
||||
|
||||
const startEnroll = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp', friendlyName: 'VYNDR' });
|
||||
if (error) {
|
||||
setEnrollState({ status: 'error', message: error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({
|
||||
status: 'enrolling',
|
||||
factorId: data.id,
|
||||
qr: data.totp.qr_code,
|
||||
secret: data.totp.secret,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (enrollState.status !== 'enrolling') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const challenge = await supabase.auth.mfa.challenge({ factorId: enrollState.factorId });
|
||||
if (challenge.error) {
|
||||
setEnrollState({ status: 'error', message: challenge.error.message });
|
||||
return;
|
||||
}
|
||||
const verifyRes = await supabase.auth.mfa.verify({
|
||||
factorId: enrollState.factorId,
|
||||
challengeId: challenge.data.id,
|
||||
code,
|
||||
});
|
||||
if (verifyRes.error) {
|
||||
setEnrollState({ status: 'error', message: verifyRes.error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({ status: 'enrolled' });
|
||||
setHasMFA(true);
|
||||
setCode('');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
if (!window.confirm('Disable two-factor authentication? Your account will be less secure.')) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
|
||||
if (!factor) return;
|
||||
const res = await supabase.auth.mfa.unenroll({ factorId: factor.id });
|
||||
if (res.error) {
|
||||
setEnrollState({ status: 'error', message: res.error.message });
|
||||
return;
|
||||
}
|
||||
setHasMFA(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || hasMFA === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12">
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Account security
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Two-factor authentication adds a one-time code on top of your password.
|
||||
</p>
|
||||
|
||||
<section
|
||||
className="mt-8 rounded-lg border p-5"
|
||||
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Authenticator app
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasMFA ? 'Enabled — codes from your authenticator are required at sign-in.' : 'Not enabled.'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
style={{
|
||||
background: hasMFA ? 'var(--grade-a)' : 'var(--bg-elevated)',
|
||||
color: hasMFA ? 'var(--bg-0)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{hasMFA ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hasMFA && enrollState.status === 'idle' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEnroll}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolling' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Scan this code with Google Authenticator, Authy, or 1Password.
|
||||
</p>
|
||||
{/* QR is a data: URI emitted by Supabase — safe to embed directly. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={enrollState.qr} alt="MFA QR code" width={180} height={180} />
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Can't scan? Enter this code manually: <code>{enrollState.secret}</code>
|
||||
</p>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="6-digit code"
|
||||
className="w-32 rounded border px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verify}
|
||||
disabled={code.length !== 6 || submitting}
|
||||
className="ml-2 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolled' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-a)' }}>
|
||||
MFA enabled. You'll be asked for a code the next time you sign in.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'error' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-d)' }}>
|
||||
{enrollState.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMFA && enrollState.status !== 'enrolling' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disable}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded border px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Disable MFA
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<ExplainModeToggle variant="full" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+154
-29
@@ -1,63 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackSignup } from '@/lib/analytics';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
function SignupInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') || '/dashboard';
|
||||
const { signUp, signInWithGoogle } = useAuth();
|
||||
|
||||
export default function SignupPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [ageOk, setAgeOk] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
// TODO: Integrate with Supabase Auth
|
||||
// const { error } = await supabase.auth.signUp({ email, password });
|
||||
setLoading(false);
|
||||
setError('Auth integration pending. Backend is ready.');
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.');
|
||||
if (password !== confirm) return setError('Passwords do not match.');
|
||||
if (!ageOk) return setError('You must confirm you are 21 or older.');
|
||||
|
||||
setBusy(true);
|
||||
const { error: err } = await signUp(email, password, ageOk);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
trackSignup({ method: 'password' });
|
||||
setDone(true);
|
||||
// Supabase confirms via email by default. If session is immediate, redirect.
|
||||
setTimeout(() => router.replace(next), 1500);
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy(true);
|
||||
await signInWithGoogle();
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut" style={{ maxWidth: 420, padding: 32, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 48, color: 'var(--grade-a)', marginBottom: 16 }}>✓</div>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 12 }}>You're in.</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Check your email for a confirmation link, then come back and grade something.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-center mb-2">Create Account</h1>
|
||||
<p className="text-center text-[var(--text-muted)] text-sm mb-8">5 free scans. No credit card required.</p>
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Get started — free</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
|
||||
5 free reads every month. Your first read is fully unlocked. No credit card.
|
||||
</p>
|
||||
|
||||
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div style={dividerStyle}>
|
||||
<span style={dividerLine} />
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>OR</span>
|
||||
<span style={dividerLine} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignup} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Email</label>
|
||||
<label className="mono" style={labelStyle}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Password</label>
|
||||
<label className="mono" style={labelStyle}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
autoComplete="new-password"
|
||||
className="input-field"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-[var(--grade-d)] text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign Up — Free'}
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="input-field"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ageOk}
|
||||
onChange={(e) => setAgeOk(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>I confirm I am 21 years of age or older.</span>
|
||||
</label>
|
||||
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Creating account…' : 'Get started — free'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-[var(--text-muted)] mt-6">
|
||||
Already have an account? <a href="/login" className="text-[var(--accent)] hover:underline">Log in</a>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.6 }}>
|
||||
By signing up you agree to our{' '}
|
||||
<a href="/terms" style={{ color: 'var(--text-secondary)' }}>Terms</a> and{' '}
|
||||
<a href="/privacy" style={{ color: 'var(--text-secondary)' }}>Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-secondary)', marginTop: 12 }}>
|
||||
Already have an account?{' '}
|
||||
<a href={`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`} style={{ color: 'var(--grade-a)' }}>
|
||||
Log in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SignupInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const dividerLine: React.CSSProperties = {
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Terms of Service',
|
||||
description: 'VYNDR terms of service. Plain English. No tricks.',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
|
||||
{
|
||||
title: 'What this is',
|
||||
body: [
|
||||
'VYNDR provides sports analysis for informational purposes only. VYNDR does not facilitate, process, or encourage any wagers. VYNDR is not a sportsbook.',
|
||||
'VYNDR Intelligence LLC operates this product. We grade player props using statistical models, historical data, and contextual intelligence.',
|
||||
],
|
||||
emphasized: true,
|
||||
},
|
||||
{
|
||||
title: 'Who can use this',
|
||||
body: [
|
||||
'You must be 21 years of age or older to create an account. You must use VYNDR only where sports betting and the use of sports analytics tools is legal in your jurisdiction. You are responsible for following your local laws.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'No guaranteed outcomes',
|
||||
body: [
|
||||
'A grade is a probabilistic assessment. An "A" grade is not a guarantee, prediction, or promise. The model is wrong all the time — we publish the ledger so you can see how often. Past performance does not guarantee future results. Do not bet what you cannot afford to lose.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Subscription terms',
|
||||
body: [
|
||||
'Paid tiers (Analyst, Desk) are billed monthly through NexaPay. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months.',
|
||||
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
|
||||
'We may change regular pricing with 30 days notice. Founder pricing is locked.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Acceptable use',
|
||||
body: [
|
||||
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Intellectual property',
|
||||
body: [
|
||||
'The grading engine, models, calibration data, factor weights, and surrounding analysis are proprietary to VYNDR Intelligence LLC. You are licensed to view grades for personal use. You are not licensed to redistribute, repackage, or sell VYNDR output.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Limitation of liability',
|
||||
body: [
|
||||
'VYNDR is not responsible for any financial losses you incur from sports betting decisions you make using our analytics. The service is provided "as is" with no warranty. Maximum liability is limited to what you paid us in the prior 12 months.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Governing law',
|
||||
body: [
|
||||
'These terms are governed by the laws of the State of Michigan. Any disputes will be resolved through binding arbitration in Wayne County, Michigan.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Responsible gambling',
|
||||
body: [
|
||||
'Sports betting can become a problem. If gambling stops being fun, stop. The National Council on Problem Gambling helpline is available 24/7, free and confidential: 1-800-522-4700. More resources at ncpgambling.org. See our Responsible Gambling page for self-exclusion guidance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
body: [
|
||||
'VYNDR Intelligence LLC. Questions about these terms: legal@vyndr.app',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalLayout
|
||||
title="Terms of Service"
|
||||
effective="Effective: May 2026"
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLayout({ title, effective, sections }: { title: string; effective: string; sections: { title: string; body: string[]; emphasized?: boolean }[] }) {
|
||||
return (
|
||||
<section style={{ maxWidth: 720, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>{title}</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
{effective.toUpperCase()}
|
||||
</p>
|
||||
</header>
|
||||
{sections.map((s) => (
|
||||
<section
|
||||
key={s.title}
|
||||
style={{
|
||||
marginBottom: 32,
|
||||
...(s.emphasized
|
||||
? {
|
||||
borderLeft: '3px solid var(--grade-a)',
|
||||
paddingLeft: 16,
|
||||
background: 'rgba(0, 212, 160, 0.04)',
|
||||
borderRadius: 4,
|
||||
padding: '12px 16px',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: s.emphasized ? 20 : 18, fontWeight: 700, marginBottom: 12 }}>{s.title}</h2>
|
||||
{s.body.map((p, i) => (
|
||||
<p
|
||||
key={i}
|
||||
style={{
|
||||
color: s.emphasized ? 'var(--text-0)' : 'var(--text-secondary)',
|
||||
fontSize: s.emphasized ? 16 : 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+227
-214
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type Period = 'weekly' | 'monthly' | 'all_time';
|
||||
|
||||
@@ -18,271 +20,282 @@ interface Bet {
|
||||
potential_payout: number;
|
||||
book: string;
|
||||
bet_type: string;
|
||||
status: string;
|
||||
slip_data: { legs: any[]; total_odds?: number; scan_session_id?: string };
|
||||
submission_method: string;
|
||||
status: 'pending' | 'won' | 'lost' | 'push' | 'void';
|
||||
slip_data: { legs: { player?: string; stat_type?: string; line?: number; direction?: string }[]; total_odds?: number };
|
||||
placed_at: string;
|
||||
settled_at: string | null;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
||||
return token
|
||||
? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
: { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export default function TrackerPage() {
|
||||
const router = useRouter();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [period, setPeriod] = useState<Period>('monthly');
|
||||
const [performance, setPerformance] = useState<Record<Period, PerfStats | null>>({ weekly: null, monthly: null, all_time: null });
|
||||
const [performance, setPerformance] = useState<Record<Period, PerfStats | null>>({
|
||||
weekly: null, monthly: null, all_time: null,
|
||||
});
|
||||
const [bets, setBets] = useState<Bet[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settlingId, setSettlingId] = useState<string | null>(null);
|
||||
const [settleStatus, setSettleStatus] = useState('won');
|
||||
const [showQuickSlip, setShowQuickSlip] = useState(false);
|
||||
|
||||
// Quick slip state
|
||||
const [showQuickSlip, setShowQuickSlip] = useState(false);
|
||||
const [slipPlayer, setSlipPlayer] = useState('');
|
||||
const [slipStat, setSlipStat] = useState('points');
|
||||
const [slipLine, setSlipLine] = useState('');
|
||||
const [slipDirection, setSlipDirection] = useState('over');
|
||||
const [slipOdds, setSlipOdds] = useState('-110');
|
||||
const [slipAmount, setSlipAmount] = useState('');
|
||||
const [slipBook, setSlipBook] = useState('draftkings');
|
||||
const [slip, setSlip] = useState({
|
||||
player: '', stat: 'points', line: '', direction: 'over',
|
||||
odds: '-110', amount: '', book: 'draftkings',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/tracker');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '20', offset: '0' });
|
||||
const params = new URLSearchParams({ limit: '30' });
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const [perfRes, betsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/bets/performance`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API_BASE}/api/bets?${params}`, { headers: getAuthHeaders() }),
|
||||
]);
|
||||
|
||||
if (perfRes.ok) {
|
||||
const perfData = await perfRes.json();
|
||||
setPerformance(perfData);
|
||||
}
|
||||
if (perfRes.ok) setPerformance(await perfRes.json());
|
||||
if (betsRes.ok) {
|
||||
const betsData = await betsRes.json();
|
||||
setBets(betsData.bets || []);
|
||||
setTotal(betsData.total || 0);
|
||||
const data = await betsRes.json();
|
||||
setBets(data.bets || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tracker data');
|
||||
} catch {
|
||||
/* network failure — keep last successful state */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleSettle = async (betId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/bets/${betId}/settle`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status: settleStatus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettlingId(null);
|
||||
fetchData();
|
||||
}
|
||||
} catch (e) { console.error('Settle failed'); }
|
||||
};
|
||||
useEffect(() => { if (user) fetchData(); }, [fetchData, user]);
|
||||
|
||||
const handleQuickSlip = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/bets/quickslip`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
legs: [{ player: slipPlayer, stat_type: slipStat, line: Number(slipLine), direction: slipDirection, odds: Number(slipOdds) }],
|
||||
amount: Number(slipAmount),
|
||||
book: slipBook,
|
||||
bet_type: 'straight',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowQuickSlip(false);
|
||||
setSlipPlayer(''); setSlipLine(''); setSlipAmount('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (e) { console.error('Quick slip failed'); }
|
||||
const res = await fetch(`${API_BASE}/api/bets/quickslip`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
legs: [{
|
||||
player: slip.player,
|
||||
stat_type: slip.stat,
|
||||
line: Number(slip.line),
|
||||
direction: slip.direction,
|
||||
odds: Number(slip.odds),
|
||||
}],
|
||||
amount: Number(slip.amount),
|
||||
book: slip.book,
|
||||
bet_type: 'straight',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowQuickSlip(false);
|
||||
setSlip({ ...slip, player: '', line: '', amount: '' });
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const settleBet = async (id: string, status: 'won' | 'lost' | 'push' | 'void') => {
|
||||
await fetch(`${API_BASE}/api/bets/${id}/settle`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const currentPerf = performance[period];
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading tracker…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-8 px-4 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Bet Tracker</h1>
|
||||
<section style={{ maxWidth: 900, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 4 }}>
|
||||
Bet tracker
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Log every bet. See what worked. The honest mirror.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition ${period === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)] hover:text-white'}`}
|
||||
>
|
||||
{p === 'all_time' ? 'All Time' : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className={`text-2xl font-mono font-bold ${(currentPerf?.roi ?? 0) >= 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
|
||||
{currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">ROI</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className="text-2xl font-mono font-bold">
|
||||
{currentPerf ? `${currentPerf.win_rate}%` : '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Win Rate</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className="text-2xl font-mono font-bold">
|
||||
{currentPerf?.sample_size ?? '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Bets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Submit */}
|
||||
<div className="flex gap-3 mb-8">
|
||||
<button
|
||||
onClick={() => setShowQuickSlip(!showQuickSlip)}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition"
|
||||
>
|
||||
Quick Slip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showQuickSlip && (
|
||||
<form onSubmit={handleQuickSlip} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] mb-8 space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<input placeholder="Player" value={slipPlayer} onChange={(e) => setSlipPlayer(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<select value={slipStat} onChange={(e) => setSlipStat(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
{['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="number" step="0.5" placeholder="Line" value={slipLine} onChange={(e) => setSlipLine(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<select value={slipDirection} onChange={(e) => setSlipDirection(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
<option value="over">Over</option><option value="under">Under</option>
|
||||
</select>
|
||||
<input type="number" placeholder="Odds (e.g. -110)" value={slipOdds} onChange={(e) => setSlipOdds(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<input type="number" step="0.01" placeholder="Amount ($)" value={slipAmount} onChange={(e) => setSlipAmount(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select value={slipBook} onChange={(e) => setSlipBook(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
{['draftkings', 'fanduel', 'betmgm'].map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
|
||||
Submit Bet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bet History */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
{['', 'pending', 'won', 'lost', 'push'].map((s) => (
|
||||
{/* Period switch */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
{(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-3 py-1 text-xs rounded-lg transition ${statusFilter === s ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)]'}`}
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={period === p ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ padding: '8px 14px', fontSize: 12, textTransform: 'capitalize' }}
|
||||
>
|
||||
{s || 'All'}
|
||||
{p === 'all_time' ? 'All time' : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<p className="text-[var(--text-muted)] text-sm">Loading...</p>
|
||||
) : bets.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)] text-sm">No bets yet. Submit your first bet above.</p>
|
||||
) : (
|
||||
bets.map((bet) => (
|
||||
<div key={bet.id} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{(bet.slip_data?.legs || []).map((l: any) =>
|
||||
`${l.player} ${l.direction?.[0]?.toUpperCase() || ''}${l.line} ${l.stat_type}`
|
||||
).join(' / ') || `${bet.bet_type} bet`}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
{bet.book} | ${bet.amount} | {new Date(bet.placed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded ${
|
||||
bet.status === 'won' ? 'bg-[var(--grade-a)]/10 text-[var(--grade-a)]' :
|
||||
bet.status === 'lost' ? 'bg-[var(--grade-d)]/10 text-[var(--grade-d)]' :
|
||||
'bg-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{bet.status.toUpperCase()}
|
||||
</span>
|
||||
{bet.status === 'won' && (
|
||||
<div className="text-xs text-[var(--grade-a)] mt-1 font-mono">
|
||||
+${(bet.potential_payout - bet.amount).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bet.status === 'pending' && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{settlingId === bet.id ? (
|
||||
<>
|
||||
<select value={settleStatus} onChange={(e) => setSettleStatus(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--bg)] border border-[var(--border)] text-white">
|
||||
<option value="won">Won</option>
|
||||
<option value="lost">Lost</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="void">Void</option>
|
||||
</select>
|
||||
<button onClick={() => handleSettle(bet.id)} className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90">
|
||||
Confirm
|
||||
</button>
|
||||
<button onClick={() => setSettlingId(null)} className="px-3 py-1 text-xs text-[var(--text-muted)]">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setSettlingId(bet.id)} className="text-xs text-[var(--accent)] hover:underline">
|
||||
Settle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Performance tiles */}
|
||||
<section
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{ padding: 24, marginBottom: 24, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}
|
||||
>
|
||||
<Tile
|
||||
label="ROI"
|
||||
value={currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '—'}
|
||||
tone={(currentPerf?.roi ?? 0) >= 0 ? 'good' : 'bad'}
|
||||
/>
|
||||
<Tile label="Win rate" value={currentPerf ? `${currentPerf.win_rate}%` : '—'} />
|
||||
<Tile label="Bets" value={currentPerf?.sample_size?.toString() ?? '—'} />
|
||||
<Tile label="Wagered" value={currentPerf ? `$${currentPerf.total_wagered.toFixed(0)}` : '—'} />
|
||||
<Tile
|
||||
label="Profit"
|
||||
value={currentPerf ? `${currentPerf.total_profit > 0 ? '+' : ''}$${currentPerf.total_profit.toFixed(0)}` : '—'}
|
||||
tone={(currentPerf?.total_profit ?? 0) >= 0 ? 'good' : 'bad'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Quick slip */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<button onClick={() => setShowQuickSlip((s) => !s)} className="btn-primary">
|
||||
{showQuickSlip ? 'Close quick slip' : '+ Log a bet'}
|
||||
</button>
|
||||
{showQuickSlip && (
|
||||
<form onSubmit={handleQuickSlip} className="surface" style={{ padding: 20, marginTop: 12, display: 'grid', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 8 }}>
|
||||
<input required placeholder="Player" className="input-field" value={slip.player} onChange={(e) => setSlip({ ...slip, player: e.target.value })} />
|
||||
<select className="input-field" value={slip.stat} onChange={(e) => setSlip({ ...slip, stat: e.target.value })}>
|
||||
{['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'].map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input required type="number" step="0.5" placeholder="Line" className="input-field" value={slip.line} onChange={(e) => setSlip({ ...slip, line: e.target.value })} />
|
||||
</div>
|
||||
))
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||
<select className="input-field" value={slip.direction} onChange={(e) => setSlip({ ...slip, direction: e.target.value })}>
|
||||
<option value="over">Over</option><option value="under">Under</option>
|
||||
</select>
|
||||
<input required type="number" placeholder="Odds" className="input-field" value={slip.odds} onChange={(e) => setSlip({ ...slip, odds: e.target.value })} />
|
||||
<input required type="number" step="0.01" placeholder="Stake $" className="input-field" value={slip.amount} onChange={(e) => setSlip({ ...slip, amount: e.target.value })} />
|
||||
<select className="input-field" value={slip.book} onChange={(e) => setSlip({ ...slip, book: e.target.value })}>
|
||||
{['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet'].map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" style={{ padding: 12 }}>Submit bet</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{['', 'pending', 'won', 'lost', 'push'].map((s) => (
|
||||
<button key={s} onClick={() => setStatusFilter(s)} className={statusFilter === s ? 'btn-primary' : 'btn-ghost'} style={{ padding: '6px 12px', fontSize: 11 }}>
|
||||
{s ? s.charAt(0).toUpperCase() + s.slice(1) : 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bet list */}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{loading ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 16 }}>Loading…</p>
|
||||
) : bets.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
No bets logged yet. Use the quick slip above to start the ledger.
|
||||
</p>
|
||||
) : (
|
||||
bets.map((b) => <BetRow key={b.id} bet={b} onSettle={settleBet} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bets.length > 0 && bets.length < total && (
|
||||
<button className="w-full mt-4 py-2 text-sm text-[var(--text-muted)] hover:text-white transition">
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
<p style={{ marginTop: 32, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
Future: connect DraftKings / FanDuel for auto-sync (coming soon).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' }) {
|
||||
const color = tone === 'good' ? 'var(--grade-a)' : tone === 'bad' ? 'var(--grade-d)' : 'var(--text-primary)';
|
||||
return (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 22, fontWeight: 800, color, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BetRow({ bet, onSettle }: { bet: Bet; onSettle: (id: string, status: 'won' | 'lost' | 'push' | 'void') => void }) {
|
||||
const [openSettle, setOpenSettle] = useState(false);
|
||||
const profit = bet.status === 'won' ? bet.potential_payout - bet.amount : 0;
|
||||
const statusColor = bet.status === 'won' ? 'var(--grade-a)' : bet.status === 'lost' ? 'var(--grade-d)' : 'var(--text-secondary)';
|
||||
return (
|
||||
<div className="surface" style={{ padding: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
|
||||
{(bet.slip_data?.legs || []).map((l) =>
|
||||
`${l.player ?? '—'} ${l.direction?.charAt(0).toUpperCase()}${l.line ?? ''} ${l.stat_type ?? ''}`
|
||||
).join(' / ') || `${bet.bet_type} bet`}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{bet.book} · ${bet.amount} · {new Date(bet.placed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: 'var(--bg-elevated)', color: statusColor }}>
|
||||
{bet.status.toUpperCase()}
|
||||
</span>
|
||||
{bet.status === 'won' && (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--grade-a)', marginTop: 4 }}>
|
||||
+${profit.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bet.status === 'pending' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{openSettle ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['won', 'lost', 'push', 'void'] as const).map((s) => (
|
||||
<button key={s} onClick={() => { setOpenSettle(false); onSettle(bet.id, s); }}
|
||||
className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11, textTransform: 'capitalize' }}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setOpenSettle(false)} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11 }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setOpenSettle(true)} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11 }}>
|
||||
Settle bet
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackUpgradeClicked } from '@/lib/analytics';
|
||||
|
||||
type Cadence = 'monthly' | 'annual';
|
||||
|
||||
const PRICES: Record<Cadence, { display: string; sub: string; checkoutParam: string }> = {
|
||||
monthly: { display: '$44.99', sub: '/mo', checkoutParam: '' },
|
||||
annual: { display: '$449.99', sub: '/yr · save 17%', checkoutParam: '&cadence=annual' },
|
||||
};
|
||||
|
||||
const EARLY_FEATURES = [
|
||||
'Unlimited reads',
|
||||
'Full factor analysis (40+ signals)',
|
||||
'Kill conditions surfaced inline',
|
||||
'Cascade alerts when lineups shift',
|
||||
'Parlay leg history with grades',
|
||||
'Sportsbook deep links',
|
||||
];
|
||||
|
||||
const DESK_EXTRAS = [
|
||||
'Real-time multi-book odds screen',
|
||||
'Live probability tracker during games',
|
||||
'Middles detection',
|
||||
'Correlation explorer',
|
||||
'Fair odds comparison (VYNDR vs book)',
|
||||
'Kelly sizing recommendations',
|
||||
'Cascade alerts with full detail',
|
||||
'Priority support',
|
||||
];
|
||||
|
||||
export default function DeskUpgradePage() {
|
||||
const router = useRouter();
|
||||
const { tier } = useAuth();
|
||||
const [cadence, setCadence] = useState<Cadence>('monthly');
|
||||
const price = PRICES[cadence];
|
||||
|
||||
const upgrade = () => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: 'desk', trigger_location: 'desk_upgrade_page' });
|
||||
router.push(`/api/checkout?tier=desk${price.checkoutParam}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 980, margin: '0 auto', padding: '40px 16px 120px' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-a)', marginBottom: 12 }}>UPGRADE</p>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 40px)', fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
The full terminal.
|
||||
</h1>
|
||||
<p style={{ marginTop: 8, color: 'var(--text-1)', fontSize: 16, maxWidth: 620, marginInline: 'auto' }}>
|
||||
Everything in Early Intelligence, plus real-time odds, live probability, and the tools the sharps use.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
display: 'grid',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--text-1)' }}>BLOOMBERG ODDS SCREEN · PREVIEW</p>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
margin: '8px auto 0',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||
gap: 4,
|
||||
maxWidth: 720,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => {
|
||||
const sample = ['—', '2.5', '+108', '-120', 'A-', 'A+'][i % 6];
|
||||
const isGrade = sample === 'A-' || sample === 'A+';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="num"
|
||||
style={{
|
||||
padding: '8px 4px',
|
||||
fontSize: 12,
|
||||
background: i % 6 === 5 ? 'rgba(0, 212, 160, 0.08)' : 'var(--bg-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
color: isGrade ? 'var(--grade-a)' : 'var(--text-1)',
|
||||
textShadow: isGrade ? '0 0 8px rgba(0, 212, 160, 0.55)' : 'none',
|
||||
}}
|
||||
>
|
||||
{sample}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TierCard
|
||||
label="Early Intelligence"
|
||||
price="$14.99"
|
||||
period="/mo"
|
||||
subtitle="Your current plan"
|
||||
features={EARLY_FEATURES}
|
||||
subdued
|
||||
/>
|
||||
<TierCard
|
||||
label="Desk"
|
||||
price={price.display}
|
||||
period={price.sub}
|
||||
subtitle="Everything above, plus:"
|
||||
features={DESK_EXTRAS}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: 'center',
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div role="tablist" aria-label="Billing cadence" style={{ display: 'inline-flex', gap: 4, background: 'var(--bg-2)', borderRadius: 999, padding: 4 }}>
|
||||
{(['monthly', 'annual'] as Cadence[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
role="tab"
|
||||
aria-selected={cadence === c}
|
||||
onClick={() => setCadence(c)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
background: cadence === c ? 'var(--bg-3)' : 'transparent',
|
||||
color: cadence === c ? 'var(--text-0)' : 'var(--text-1)',
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{c.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 40,
|
||||
color: 'var(--grade-a)',
|
||||
textShadow: '0 0 18px rgba(0, 212, 160, 0.7)',
|
||||
lineHeight: 1,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{price.display}
|
||||
<span style={{ fontSize: 14, color: 'var(--text-1)' }}>{price.sub}</span>
|
||||
</p>
|
||||
|
||||
{tier !== 'desk' && (
|
||||
<p className="lbl" style={{ color: 'var(--text-1)' }}>
|
||||
You're currently on {tier === 'analyst' ? 'Early Intelligence' : 'Free'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
style={{ padding: '14px 32px', fontSize: 15, marginTop: 4 }}
|
||||
onClick={upgrade}
|
||||
>
|
||||
Upgrade to Desk →
|
||||
</button>
|
||||
|
||||
<p style={{ color: 'var(--text-2)', fontSize: 12 }}>
|
||||
Questions? <a href="mailto:support@vyndr.app" style={{ color: 'var(--text-1)' }}>support@vyndr.app</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCard({
|
||||
label,
|
||||
price,
|
||||
period,
|
||||
subtitle,
|
||||
features,
|
||||
subdued,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
price: string;
|
||||
period: string;
|
||||
subtitle: string;
|
||||
features: string[];
|
||||
subdued?: boolean;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={highlight ? 'surface diagonal-cut tex-scan' : 'surface'}
|
||||
style={{
|
||||
padding: 24,
|
||||
opacity: subdued ? 0.85 : 1,
|
||||
borderColor: highlight ? 'var(--grade-a)' : undefined,
|
||||
boxShadow: highlight ? '0 0 0 1px rgba(0,212,160,0.25), 0 12px 40px rgba(0,212,160,0.15)' : undefined,
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: highlight ? 'var(--grade-a)' : 'var(--text-1)' }}>{label.toUpperCase()}</p>
|
||||
<p
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 28,
|
||||
marginTop: 6,
|
||||
color: highlight ? 'var(--grade-a)' : 'var(--text-0)',
|
||||
textShadow: highlight ? '0 0 14px rgba(0, 212, 160, 0.55)' : 'none',
|
||||
}}
|
||||
>
|
||||
{price}<span style={{ fontSize: 13, color: 'var(--text-1)' }}>{period}</span>
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-1)', marginTop: 4 }}>{subtitle}</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}>
|
||||
{features.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}>
|
||||
<span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'IBM Plex Mono, monospace' }}>›</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
function VerifyInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const emailFromQuery = search.get('email') || '';
|
||||
const { user } = useAuth();
|
||||
const [email, setEmail] = useState(emailFromQuery || user?.email || '');
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [resentAt, setResentAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.email_confirmed_at) {
|
||||
router.replace('/welcome');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const id = setInterval(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [cooldown]);
|
||||
|
||||
const resend = async () => {
|
||||
setError('');
|
||||
if (!email) return setError('Enter the email you signed up with.');
|
||||
if (cooldown > 0) return;
|
||||
setSending(true);
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) {
|
||||
setSending(false);
|
||||
return setError('Auth is not configured.');
|
||||
}
|
||||
const { error: err } = await supabase.auth.resend({ type: 'signup', email });
|
||||
setSending(false);
|
||||
if (err) return setError(err.message);
|
||||
setResentAt(Date.now());
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<div style={{ textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}>
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="var(--grade-a)" strokeWidth="1.5" />
|
||||
<path d="M3 7l9 6 9-6" stroke="var(--grade-a)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700 }}>Check your inbox</h1>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 320 }}>
|
||||
We sent a verification link to{' '}
|
||||
<span className="mono" style={{ color: 'var(--text-0)' }}>{email || 'your email'}</span>.
|
||||
</p>
|
||||
|
||||
{!emailFromQuery && !user && (
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@vyndr.app"
|
||||
className="input-field"
|
||||
style={{ marginTop: 8, maxWidth: 280 }}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13 }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={sending || cooldown > 0}
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{sending ? 'Sending…' : cooldown > 0 ? `Resend in ${cooldown}s` : "Didn't get it? Resend"}
|
||||
</button>
|
||||
|
||||
{resentAt ? (
|
||||
<p className="lbl" style={{ color: 'var(--grade-a)', marginTop: 4 }}>SENT</p>
|
||||
) : null}
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-1)', marginTop: 16 }}>
|
||||
Wrong email?{' '}
|
||||
<a href="/signup" style={{ color: 'var(--grade-a)' }}>Sign up again</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<VerifyInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function WelcomePage() {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace('/signup');
|
||||
}, [loading, user, router]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tex-scan"
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: 'calc(100vh - 144px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '40px 24px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span className="v-watermark" aria-hidden>V</span>
|
||||
|
||||
<SlashedYMark />
|
||||
|
||||
<h1
|
||||
style={{
|
||||
marginTop: 32,
|
||||
fontSize: 'clamp(26px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--text-0)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Welcome to VYNDR.
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: 'var(--text-1)',
|
||||
maxWidth: 460,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Intelligence the books don't want you to have.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 28, display: 'grid', gap: 12, zIndex: 1 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
style={{ padding: '14px 32px', fontSize: 15 }}
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Get Started →
|
||||
</button>
|
||||
<p className="lbl" style={{ color: 'var(--text-2)' }}>BUILT IN DETROIT</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashedYMark() {
|
||||
return (
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
aria-hidden
|
||||
style={{
|
||||
filter: 'drop-shadow(0 0 18px rgba(0, 212, 160, 0.55)) drop-shadow(0 0 36px rgba(0, 212, 160, 0.25))',
|
||||
animation: 'phosphor-pulse 2.4s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ymark-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#00FFB8" />
|
||||
<stop offset="100%" stopColor="#00D4A0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke="url(#ymark-grad)" strokeWidth="10" strokeLinecap="round" fill="none">
|
||||
<line x1="20" y1="20" x2="60" y2="64" />
|
||||
<line x1="60" y1="64" x2="60" y2="100" />
|
||||
<line x1="8" y1="110" x2="112" y2="6" strokeWidth="9" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
|
||||
{ id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon },
|
||||
{ id: 'parlay', label: 'Parlay', href: null, icon: ParlayIcon },
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
||||
{ id: 'profile', label: 'Profile', href: '/profile', icon: ProfileIcon },
|
||||
] as const;
|
||||
|
||||
// Pages where the bottom tab bar should stay hidden (auth flows, landing).
|
||||
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
|
||||
|
||||
export default function BottomTabBar() {
|
||||
const pathname = usePathname() || '/';
|
||||
const { open, legCount } = useParlay();
|
||||
|
||||
if (HIDE_ON.has(pathname)) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Primary"
|
||||
className="mobile-tab-bar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 64,
|
||||
zIndex: 40,
|
||||
display: 'flex',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'rgba(10,10,15,0.92)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const active = t.href ? (pathname === t.href || pathname.startsWith(`${t.href}/`)) : false;
|
||||
const color = active ? 'var(--grade-a)' : 'var(--text-secondary)';
|
||||
const Icon = t.icon;
|
||||
const isParlay = t.id === 'parlay';
|
||||
const onClick = () => {
|
||||
if (isParlay) open();
|
||||
};
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
color,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'inherit',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Icon color={color} />
|
||||
<span>{t.label}</span>
|
||||
{isParlay && legCount > 0 && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 'calc(50% - 22px)',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-primary)',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{legCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isParlay || !t.href) {
|
||||
return (
|
||||
<button key={t.id} onClick={onClick} style={{ flex: 1, background: 'transparent', border: 'none', padding: 0 }}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a key={t.id} href={t.href} style={{ flex: 1, padding: 0, textDecoration: 'none' }}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
<style jsx>{`
|
||||
@media (min-width: 768px) {
|
||||
:global(.mobile-tab-bar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight inline SVG icons — keeps the bundle slim and avoids icon-lib install
|
||||
function HomeIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12 12 3l9 9" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ParlayIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="10" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="16" width="18" height="4" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16v16H4z" />
|
||||
<path d="M4 9h16" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GradePill } from './GradeCard';
|
||||
|
||||
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
|
||||
const BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers'];
|
||||
|
||||
const ACCURACY: Record<string, string> = {
|
||||
A: '73%',
|
||||
B: '61%',
|
||||
C: '48%',
|
||||
D: '34%',
|
||||
};
|
||||
|
||||
interface KillCondition {
|
||||
code: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface DemoResult {
|
||||
grade: string;
|
||||
confidence: number;
|
||||
edge_pct: number;
|
||||
kill_conditions_triggered: KillCondition[];
|
||||
reasoning: { summary: string };
|
||||
implied_probability?: number;
|
||||
}
|
||||
|
||||
function oddsToImplied(odds: number): number {
|
||||
if (odds > 0) return Math.round((100 / (odds + 100)) * 1000) / 10;
|
||||
return Math.round(((-odds) / (-odds + 100)) * 1000) / 10;
|
||||
}
|
||||
|
||||
export default function DemoScan() {
|
||||
const [player, setPlayer] = useState('');
|
||||
const [statType, setStatType] = useState('points');
|
||||
const [line, setLine] = useState('');
|
||||
const [direction, setDirection] = useState('over');
|
||||
const [book, setBook] = useState('draftkings');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<DemoResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Live stats
|
||||
const [stats, setStats] = useState<{ parlays_graded: number; kill_conditions_caught: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch('/api/stats/public');
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch {
|
||||
setStats(null);
|
||||
}
|
||||
}
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!player || !line) { setError('Enter a player name and line.'); return; }
|
||||
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/analyze/prop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player,
|
||||
stat_type: statType,
|
||||
line: Number(line),
|
||||
direction,
|
||||
book,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Analysis failed');
|
||||
|
||||
// Default implied probability for standard -110 line
|
||||
const implied = oddsToImplied(-110);
|
||||
setResult({ ...data, implied_probability: implied });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 px-4 bg-[var(--card)]">
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-2">See it work. Right now.</h2>
|
||||
<p className="text-[var(--text-muted)] text-sm">No account. No card. One prop read.</p>
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
<>
|
||||
{/* Form — single column, mobile-first */}
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
placeholder="Player name"
|
||||
value={player}
|
||||
onChange={(e) => setPlayer(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--cyan)]"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={statType}
|
||||
onChange={(e) => setStatType(e.target.value)}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
>
|
||||
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="Line (e.g. 24.5)"
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value)}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
>
|
||||
<option value="over">Over</option>
|
||||
<option value="under">Under</option>
|
||||
</select>
|
||||
<select
|
||||
value={book}
|
||||
onChange={(e) => setBook(e.target.value)}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
>
|
||||
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-[var(--kill)]">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning || !player || !line}
|
||||
className="w-full mt-4 py-3.5 bg-[var(--cyan)] text-black font-semibold rounded-xl text-sm hover:bg-[var(--cyan-hover)] transition disabled:opacity-40"
|
||||
>
|
||||
{scanning ? 'Reading...' : 'Read This Prop'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Result */}
|
||||
<div className="p-5 rounded-2xl bg-[var(--forest-dark)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">{result.grade === 'A' || result.grade === 'B' ? player : player}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{direction.charAt(0).toUpperCase() + direction.slice(1)} {line} {statType}
|
||||
</p>
|
||||
</div>
|
||||
<GradePill grade={result.grade} confidence={result.confidence} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-4">
|
||||
{result.reasoning.summary}
|
||||
</p>
|
||||
|
||||
{/* Kill conditions */}
|
||||
{result.kill_conditions_triggered.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-[var(--kill)]/10 border border-[var(--kill)]/30 mb-4">
|
||||
{result.kill_conditions_triggered.map((k) => (
|
||||
<div key={k.code} className="flex items-start gap-2 text-sm mb-1 last:mb-0">
|
||||
<span className="text-[var(--kill)] font-mono text-xs font-bold">{k.code}</span>
|
||||
<span className="text-[var(--kill)]">{k.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accuracy context */}
|
||||
<p className="text-xs text-[var(--text-muted)] mb-2">
|
||||
{result.grade} grades like this hit {ACCURACY[result.grade] || '—'} of the time based on our model accuracy to date.
|
||||
</p>
|
||||
|
||||
{/* Implied probability */}
|
||||
{result.implied_probability != null && (
|
||||
<>
|
||||
<p className="text-sm font-mono text-[var(--cyan)]">
|
||||
Implied probability: {result.implied_probability}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-dim)] mt-1">
|
||||
Your book already knows this number.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post-scan CTA */}
|
||||
<div className="mt-6 text-center space-y-3">
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
This used 1 of your 5 free reads.
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Sign up free to read your full parlay.
|
||||
</p>
|
||||
<a
|
||||
href="/signup"
|
||||
className="inline-block w-full py-3.5 bg-[var(--cyan)] text-black font-semibold rounded-xl text-sm hover:bg-[var(--cyan-hover)] transition text-center"
|
||||
>
|
||||
Read Your Full Parlay Free
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setResult(null)}
|
||||
className="w-full py-3 border border-[var(--border)] rounded-xl text-sm text-[var(--text-muted)] hover:border-[var(--cyan)] transition"
|
||||
>
|
||||
Try Another Prop
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Honest Stats */}
|
||||
<div className="mt-12 grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-mono font-bold text-[var(--grade-a)]">73%</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">A Grade Accuracy</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-mono font-bold">
|
||||
{stats?.kill_conditions_caught?.toLocaleString() ?? '—'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Kills Caught</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-mono font-bold">
|
||||
{stats?.parlays_graded?.toLocaleString() ?? '—'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Parlays Graded</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-[var(--text-dim)] mt-3">
|
||||
Live model data. Updated in real time.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
type ErrorStateProps = {
|
||||
label?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
};
|
||||
|
||||
export default function ErrorState({
|
||||
label = 'CONNECTION LOST',
|
||||
title = "Can't reach the signal.",
|
||||
body = 'Check your connection and try again.',
|
||||
onRetry,
|
||||
retryLabel = 'Retry',
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 24px',
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>{label}</p>
|
||||
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)' }}>{title}</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14 }}>{body}</p>
|
||||
{onRetry ? (
|
||||
<button className="btn-primary" style={{ marginTop: 16 }} onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaintenanceState({
|
||||
title = 'VYNDR is recalibrating the model.',
|
||||
body = 'The engine improves itself after every game night. This is that process. Back in a few minutes.',
|
||||
}: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-a)' }}>RECALIBRATING</p>
|
||||
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 460 }}>{title}</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 200,
|
||||
height: 2,
|
||||
marginTop: 16,
|
||||
background: 'linear-gradient(90deg, transparent, var(--grade-a), transparent)',
|
||||
boxShadow: '0 0 12px rgba(0, 212, 160, 0.6)',
|
||||
animation: 'phosphor-pulse 1.8s ease-in-out infinite',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useExplainMode } from '@/contexts/ExplainModeContext';
|
||||
|
||||
interface ExplainModeToggleProps {
|
||||
variant?: 'compact' | 'full';
|
||||
}
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.06 10.06 0 0 1 12 19c-6.5 0-10-7-10-7a17.81 17.81 0 0 1 4.06-5.06" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a17.81 17.81 0 0 1-3.06 3.94" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExplainModeToggle({ variant = 'compact' }: ExplainModeToggleProps) {
|
||||
const { explainMode, toggleExplainMode } = useExplainMode();
|
||||
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center justify-between gap-3 rounded border p-3"
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-semibold">Explain Like I'm New</span>
|
||||
<span className="block text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Adds plain-English notes under each number, grade, and signal.
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={explainMode}
|
||||
onChange={toggleExplainMode}
|
||||
aria-label="Toggle Explain Like I'm New"
|
||||
className="h-5 w-9 cursor-pointer appearance-none rounded-full transition-colors"
|
||||
style={{
|
||||
background: explainMode ? 'var(--grade-a)' : 'var(--bg-elevated)',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExplainMode}
|
||||
aria-pressed={explainMode}
|
||||
aria-label={explainMode ? 'Disable explanations' : 'Enable explanations'}
|
||||
title={explainMode ? 'Explanations on' : 'Explanations off'}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold"
|
||||
style={{
|
||||
color: explainMode ? 'var(--grade-a)' : 'var(--text-tertiary)',
|
||||
background: explainMode ? 'rgba(0,212,160,0.10)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: explainMode ? 'rgba(0,212,160,0.30)' : 'var(--border-light)',
|
||||
}}
|
||||
>
|
||||
<EyeIcon open={explainMode} />
|
||||
{explainMode && <span>Beginner</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useExplainMode } from '@/contexts/ExplainModeContext';
|
||||
|
||||
interface ExplainTooltipProps {
|
||||
explanation: string;
|
||||
children: React.ReactNode;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// Wraps an element. When Explain Mode is on, renders a small annotation
|
||||
// directly below `children` describing what the wrapped element means.
|
||||
// When off, renders children unchanged with no DOM cost.
|
||||
|
||||
export default function ExplainTooltip({ explanation, children, inline = false }: ExplainTooltipProps) {
|
||||
const { explainMode } = useExplainMode();
|
||||
|
||||
if (!explainMode) return <>{children}</>;
|
||||
|
||||
const wrapperTag = inline ? 'span' : 'div';
|
||||
const Wrapper = wrapperTag as 'span';
|
||||
return (
|
||||
<Wrapper className="explain-wrap" style={{ display: inline ? 'inline-block' : 'block' }}>
|
||||
{children}
|
||||
<span
|
||||
role="note"
|
||||
className="explain-tip"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 6,
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'rgba(0, 212, 160, 0.10)',
|
||||
border: '1px solid rgba(0, 212, 160, 0.30)',
|
||||
borderRadius: 6,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" style={{ marginRight: 6, opacity: 0.7 }}>?</span>
|
||||
{explanation}
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: 'Is this a sportsbook?',
|
||||
a: 'No. We don\'t take bets. We grade props so you make better ones. VYNDR is an analytics platform — you bring the prop, we show you every angle on it.',
|
||||
},
|
||||
{
|
||||
q: 'How accurate is the model?',
|
||||
a: 'Check the ledger. Every grade, every result, updated nightly. We don\'t hide misses. Brier score and CLV are tracked from day one and published.',
|
||||
},
|
||||
{
|
||||
q: 'What sports do you cover?',
|
||||
a: 'NBA, MLB, and WNBA at launch. NFL is targeted for September 2026. Each sport has its own calibrated weights and sport-specific factor models.',
|
||||
},
|
||||
{
|
||||
q: 'Can I cancel anytime?',
|
||||
a: 'Yes. No contracts. No cancellation fees. No guilt-trip retention emails. Your access continues through the end of the billing period.',
|
||||
},
|
||||
{
|
||||
q: 'What is the Founder Access price?',
|
||||
a: 'First 100 users lock $14.99/mo for life. After that the price moves to $24.99/mo and never comes back to $14.99. Locked-in pricing carries if you maintain continuous subscription.',
|
||||
},
|
||||
{
|
||||
q: 'How is payment processed?',
|
||||
a: 'We use NexaPay. You pay with Visa, Mastercard, Apple Pay, or Google Pay. We never see your card data. We are PCI-out-of-scope.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQ() {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
padding: '96px 24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 760, margin: '0 auto' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 'clamp(28px, 4vw, 44px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Questions, answered.
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{FAQS.map((faq, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div
|
||||
key={faq.q}
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 200ms ease',
|
||||
borderColor: isOpen ? 'var(--border-focus)' : 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '18px 24px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<span>{faq.q}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 18,
|
||||
transform: isOpen ? 'rotate(45deg)' : 'rotate(0)',
|
||||
transition: 'transform 200ms ease',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0 24px 20px',
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{faq.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,119 @@
|
||||
const features = [
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Prop Analysis',
|
||||
description: '6-step grading pipeline. Season average, recent form, situational splits, cross-book lines, kill conditions.',
|
||||
icon: '◆',
|
||||
title: 'Multi-dimensional player archetypes',
|
||||
body: 'Players aren\'t one thing. Our model scores every dimension — pitcher discipline, batter approach, NBA usage shape — and blends them per matchup.',
|
||||
},
|
||||
{
|
||||
title: 'Correlation Detection',
|
||||
description: 'Flags conflicting legs in your parlay. Same-game overlap, opposing players, contradictory props.',
|
||||
icon: '↻',
|
||||
title: 'Auto-calibrating engine',
|
||||
body: 'Every resolved grade trains the next one. Point-biserial weight tuning, per-stat calibration, blind-spot detection. The model improves itself.',
|
||||
},
|
||||
{
|
||||
title: 'Line Movement',
|
||||
description: 'Tracks lines throughout the day. Alerts when movement hits 0.5+ points. Sharp money indicators.',
|
||||
icon: '⚡',
|
||||
title: 'Beat reporter intelligence',
|
||||
body: 'Lineup intel from the people closest to the team — 30 minutes before tip. Trust-tiered, redistribution-aware, line-correlated.',
|
||||
},
|
||||
{
|
||||
title: 'Kill Conditions',
|
||||
description: '6 hard checks before you bet. Low minutes, small sample, back-to-back, blowout risk, split conflicts.',
|
||||
icon: '⊘',
|
||||
title: 'Kill conditions',
|
||||
body: 'We don\'t just grade the prop. We tell you what kills it. Six hard checks per read: minutes, sample, fatigue, blowout risk, splits, line conflict.',
|
||||
},
|
||||
{
|
||||
title: 'Bet Tracking',
|
||||
description: 'Log every bet. Screenshot upload, quick slip, or manual entry. Track ROI and win rate over time.',
|
||||
icon: '∿',
|
||||
title: 'Parlay correlation math',
|
||||
body: 'Phi-coefficient analysis catches the legs that secretly fight each other. The books love correlated unders. We surface them.',
|
||||
},
|
||||
{
|
||||
title: 'Cascade Alerts',
|
||||
description: 'Star player scratched? BetonBLK re-grades your affected parlays and alerts you instantly.',
|
||||
icon: '⌧',
|
||||
title: 'ABS intelligence (MLB)',
|
||||
body: 'The automated strike zone changes everything. Per-pitcher, per-batter discipline scoring. Zone 14 framing loss. Challenge math.',
|
||||
},
|
||||
{
|
||||
icon: '◯',
|
||||
title: 'Three sports, one engine',
|
||||
body: 'NBA. MLB. WNBA. Unified intelligence layer with sport-specific calibration. NFL coming September 2026.',
|
||||
},
|
||||
{
|
||||
icon: '⌦',
|
||||
title: 'The honest ledger',
|
||||
body: 'Every grade. Every result. No hiding. No deletion. Brier score and CLV from day one. Public accuracy by tier.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section className="py-24 px-4 bg-[var(--card)]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-4">Built for Serious Bettors</h2>
|
||||
<p className="text-[var(--text-muted)] text-center mb-16 max-w-lg mx-auto">
|
||||
Every feature exists because we needed it ourselves. No fluff.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="p-5 rounded-xl border border-[var(--border)] bg-[var(--bg)]">
|
||||
<h3 className="font-semibold mb-2">{f.title}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed">{f.description}</p>
|
||||
<section
|
||||
style={{
|
||||
padding: '96px 24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
|
||||
<h2
|
||||
className="text-balance"
|
||||
style={{
|
||||
fontSize: 'clamp(28px, 4vw, 44px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
One platform. Everything connected.
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
||||
Built by bettors who got tired of switching between five tabs to grade one prop.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
}}
|
||||
className="features-grid"
|
||||
>
|
||||
{FEATURES.map((f, i) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className={`surface surface-hover diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
|
||||
style={{ padding: 24 }}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: 'var(--grade-a)',
|
||||
marginBottom: 16,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>{f.title}</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{f.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
:global(.features-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
:global(.features-grid) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
:global(.features-grid) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+136
-50
@@ -1,62 +1,148 @@
|
||||
'use client';
|
||||
const PRIMARY_LINKS = [
|
||||
{ label: 'Read', href: '/scan' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'Ledger', href: '/ledger' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
];
|
||||
|
||||
import { useState } from 'react';
|
||||
const LEGAL_LINKS = [
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
|
||||
];
|
||||
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
const SOCIAL = [
|
||||
{ label: 'Twitter', href: 'https://twitter.com/getvyndr' },
|
||||
{ label: 'Discord', href: 'https://discord.gg/getvyndr' },
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Store email in Supabase
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="py-16 px-4 border-t border-[var(--border)]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 mb-12">
|
||||
<div>
|
||||
<h3 className="font-mono font-bold text-lg mb-2">
|
||||
Beton<span className="text-[var(--accent)]">BLK</span>
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-sm">
|
||||
AI-powered parlay intelligence. Built by bettors, for bettors.
|
||||
<footer
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '64px 24px 32px',
|
||||
marginTop: 64,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 48,
|
||||
marginBottom: 48,
|
||||
}}
|
||||
className="footer-top"
|
||||
>
|
||||
<div style={{ maxWidth: 400 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={24} />
|
||||
</a>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
The books have every advantage. We built this to give it back.
|
||||
</p>
|
||||
<p className="mono" style={{ marginTop: 16, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
Built in Detroit.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Get early access + founder pricing</h4>
|
||||
{submitted ? (
|
||||
<p className="text-[var(--grade-a)] text-sm">You're in. We'll be in touch.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FooterColumn title="Product" links={PRIMARY_LINKS} />
|
||||
<FooterColumn title="Legal" links={LEGAL_LINKS} />
|
||||
<FooterColumn title="Community" links={SOCIAL} external />
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs text-[var(--text-muted)] border-t border-[var(--border)] pt-6">
|
||||
<span>2026 BetonBLK. All rights reserved.</span>
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="hover:text-white transition">Terms</a>
|
||||
<a href="#" className="hover:text-white transition">Privacy</a>
|
||||
<a href="#" className="hover:text-white transition">Twitter/X</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 24,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.6 }}>
|
||||
VYNDR is an analytics tool, not a sportsbook. We don't accept wagers. Gamble responsibly.
|
||||
If you or someone you know has a gambling problem, call <strong style={{ color: 'var(--text-secondary)' }}>1-800-522-4700</strong>{' '}
|
||||
or visit <a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--grade-a)' }}>ncpgambling.org</a>.
|
||||
</p>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
© 2026 VYNDR. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
:global(.footer-top) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:global(.footer-top) {
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterColumn({
|
||||
title,
|
||||
links,
|
||||
external,
|
||||
}: {
|
||||
title: string;
|
||||
links: { label: string; href: string }[];
|
||||
external?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{links.map((l) => (
|
||||
<li key={l.label}>
|
||||
<a
|
||||
href={l.href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 14,
|
||||
transition: 'color 200ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,534 @@
|
||||
const gradeColors: Record<string, string> = {
|
||||
A: 'bg-[var(--grade-a)]/10 border-[var(--grade-a)] text-[var(--grade-a)]',
|
||||
B: 'bg-[var(--grade-b)]/10 border-[var(--grade-b)] text-[var(--grade-b)]',
|
||||
C: 'bg-[var(--grade-c)]/10 border-[var(--grade-c)] text-[var(--grade-c)]',
|
||||
D: 'bg-[var(--grade-d)]/10 border-[var(--grade-d)] text-[var(--grade-d)]',
|
||||
};
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ExplainTooltip from '@/components/ExplainTooltip';
|
||||
import ExplainModeToggle from '@/components/ExplainModeToggle';
|
||||
import { markReadComplete } from '@/lib/reads';
|
||||
|
||||
// Short, plain-English explanations rendered when Explain Like I'm New is on.
|
||||
// Each key maps to one piece of data we surface on this card.
|
||||
const EXPLANATIONS = {
|
||||
grade: "Our overall confidence. A-minus means we estimate about a 76% chance this prop hits, based on 40+ factors.",
|
||||
projection: "What our model predicts the player will actually do tonight for this stat.",
|
||||
line: "The number the sportsbook set. The player needs to go over or under it.",
|
||||
overUnder: 'Over = the player needs MORE than the line. Under = LESS.',
|
||||
confidence: "How much data we have on this player and stat. More games = more reliable.",
|
||||
killConditions: "Red flags we detected that could cause this prop to miss regardless of the stats.",
|
||||
factors: "The signals our engine weighs — recent form, matchup, rest, usage, etc.",
|
||||
} as const;
|
||||
|
||||
export type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
export type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
export interface KillCondition {
|
||||
code: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AltLine {
|
||||
line: number;
|
||||
grade: string;
|
||||
hit_rate?: number;
|
||||
edge_pct?: number;
|
||||
}
|
||||
|
||||
export interface FactorAnalysis {
|
||||
matchup?: string;
|
||||
trend?: string;
|
||||
usage?: string;
|
||||
minutes?: string;
|
||||
pace?: string;
|
||||
rest?: string;
|
||||
weather?: string;
|
||||
abs?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface GradeCardProps {
|
||||
sport: Sport;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: FactorAnalysis;
|
||||
alt_lines?: AltLine[];
|
||||
kill_conditions?: KillCondition[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
tier: Tier;
|
||||
onUpgradeClick?: (target: 'analyst' | 'desk', from: string) => void;
|
||||
onAddToParlay?: () => void;
|
||||
onShare?: () => void;
|
||||
trending?: boolean;
|
||||
}
|
||||
|
||||
const SPORTSBOOKS = [
|
||||
{ id: 'draftkings', label: 'DK', color: '#53D337', host: 'sportsbook.draftkings.com' },
|
||||
{ id: 'fanduel', label: 'FD', color: '#1493FF', host: 'sportsbook.fanduel.com' },
|
||||
{ id: 'betmgm', label: 'MGM', color: '#BB9959', host: 'sports.betmgm.com' },
|
||||
{ id: 'caesars', label: 'Caesars', color: '#C8A35F', host: 'sportsbook.caesars.com' },
|
||||
{ id: 'pointsbet', label: 'PB', color: '#E2231A', host: 'pointsbet.com' },
|
||||
];
|
||||
|
||||
function gradeTierClass(grade: string): { color: string; bg: string; border: string } {
|
||||
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||
if (g === 'A') return { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.10)', border: 'rgba(0,200,150,0.40)' };
|
||||
if (g === 'B') return { color: 'var(--grade-b)', bg: 'rgba(74,158,255,0.10)', border: 'rgba(74,158,255,0.40)' };
|
||||
if (g === 'C') return { color: 'var(--grade-c)', bg: 'rgba(255,179,71,0.10)', border: 'rgba(255,179,71,0.40)' };
|
||||
return { color: 'var(--grade-d)', bg: 'rgba(255,107,107,0.10)', border: 'rgba(255,107,107,0.40)' };
|
||||
}
|
||||
|
||||
function confidenceLabel(sample?: number): { label: string; tone: 'high' | 'moderate' | 'limited' } {
|
||||
const n = sample ?? 0;
|
||||
if (n >= 30) return { label: `High confidence (${n} games)`, tone: 'high' };
|
||||
if (n >= 12) return { label: `Moderate confidence (${n} games)`, tone: 'moderate' };
|
||||
return { label: `Limited data (${Math.max(0, n)} games)`, tone: 'limited' };
|
||||
}
|
||||
|
||||
function deepLink(host: string, player: string): string {
|
||||
const slug = encodeURIComponent(player);
|
||||
return `https://${host}/?search=${slug}`;
|
||||
}
|
||||
|
||||
export default function GradeCard(props: GradeCardProps) {
|
||||
const tone = gradeTierClass(props.grade);
|
||||
const conf = confidenceLabel(props.sample_size);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
// Animate the grade letter on first paint
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRevealed(true), 50);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [props.grade]);
|
||||
|
||||
// Mark this card as ONE read for the InstallPrompt / PushPrompt gates.
|
||||
// GradeCardProps doesn't carry a server-side id, so build a stable
|
||||
// composite key from the canonical identifying fields. Per-session
|
||||
// dedupe — viewing the same prop twice in one session counts once.
|
||||
useEffect(() => {
|
||||
if (!revealed || typeof window === 'undefined') return;
|
||||
const readKey = `vyndr_read_${props.sport}_${props.player}_${props.stat}_${props.line}_${props.direction}`;
|
||||
if (!window.sessionStorage.getItem(readKey)) {
|
||||
window.sessionStorage.setItem(readKey, '1');
|
||||
markReadComplete();
|
||||
}
|
||||
}, [revealed, props.sport, props.player, props.stat, props.line, props.direction]);
|
||||
|
||||
const showFactors = props.tier !== 'free';
|
||||
const showAltLines = props.tier === 'desk';
|
||||
const sportBadge = useMemo(() => {
|
||||
const s = props.sport;
|
||||
if (s === 'NBA') return { color: '#E94B3C' };
|
||||
if (s === 'MLB') return { color: '#1E90FF' };
|
||||
return { color: '#FFB347' };
|
||||
}, [props.sport]);
|
||||
|
||||
export default function GradeCard({ grade, confidence, label }: { grade: string; confidence?: number; label?: string }) {
|
||||
const colors = gradeColors[grade] || gradeColors.D;
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-3 px-4 py-2 rounded-xl border ${colors}`}>
|
||||
<span className="font-mono font-bold text-3xl">{grade}</span>
|
||||
{confidence != null && (
|
||||
<div className="text-sm">
|
||||
<div className="font-mono font-medium">{confidence}%</div>
|
||||
{label && <div className="text-xs opacity-70">{label}</div>}
|
||||
<article
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{ padding: 24, maxWidth: 560, width: '100%' }}
|
||||
aria-label={`Grade card for ${props.player}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${sportBadge.color}1F`,
|
||||
color: sportBadge.color,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{props.sport}
|
||||
</span>
|
||||
{props.trending && (
|
||||
<span
|
||||
className="mono"
|
||||
title="Trending in parlays tonight"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,179,71,0.15)',
|
||||
color: 'var(--grade-c)',
|
||||
}}
|
||||
>
|
||||
Trending in parlays
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 2 }}>{props.player}</h3>
|
||||
<ExplainTooltip explanation={EXPLANATIONS.overUnder}>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
</ExplainTooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ExplainModeToggle variant="compact" />
|
||||
{props.onShare && (
|
||||
<button
|
||||
onClick={props.onShare}
|
||||
aria-label="Share grade"
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grade letter — the hero */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '24px 0' }}>
|
||||
<ExplainTooltip explanation={EXPLANATIONS.grade}>
|
||||
<div
|
||||
className={revealed ? 'animate-grade mono' : 'mono'}
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
color: tone.color,
|
||||
textShadow: `0 0 40px ${tone.color}33`,
|
||||
padding: '16px 32px',
|
||||
borderRadius: 16,
|
||||
background: `radial-gradient(circle at center, ${tone.bg} 0%, transparent 70%)`,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
{props.grade || '—'}
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
</div>
|
||||
|
||||
{/* Projection + confidence */}
|
||||
{(props.projection != null || props.sample_size != null) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: props.projection != null && props.sample_size != null ? '1fr 1fr' : '1fr',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{props.projection != null && (
|
||||
<ExplainTooltip explanation={EXPLANATIONS.projection}>
|
||||
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Projection</div>
|
||||
<div className="mono" style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{props.projection.toFixed(1)} {props.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
)}
|
||||
{props.sample_size != null && (
|
||||
<ExplainTooltip explanation={EXPLANATIONS.confidence}>
|
||||
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Confidence</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: conf.tone === 'limited' ? 'var(--grade-c)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{conf.label}
|
||||
</div>
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Factor analysis — gated for free tier */}
|
||||
<FactorBlock
|
||||
factors={props.factors}
|
||||
killConditions={props.kill_conditions}
|
||||
gated={!showFactors}
|
||||
onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_factors')}
|
||||
/>
|
||||
|
||||
{/* Alt lines — gated for free + analyst */}
|
||||
<AltLineBlock
|
||||
altLines={props.alt_lines}
|
||||
gated={!showAltLines}
|
||||
currentTier={props.tier}
|
||||
onUpgrade={() => props.onUpgradeClick?.('desk', 'grade_card_alt_lines')}
|
||||
/>
|
||||
|
||||
{/* Reasoning */}
|
||||
{props.reasoning && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<SectionLabel>Model reasoning</SectionLabel>
|
||||
{showFactors ? (
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{props.reasoning}</p>
|
||||
) : (
|
||||
<BlurredText text={props.reasoning} onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_reasoning')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical accuracy */}
|
||||
{props.historical_hit_rate != null && (
|
||||
<p style={{ marginTop: 12, fontSize: 12, color: 'var(--text-tertiary)' }} className="mono">
|
||||
{props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sportsbook deep links */}
|
||||
<div style={{ marginTop: 20, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{SPORTSBOOKS.map((book) => (
|
||||
<a
|
||||
key={book.id}
|
||||
href={deepLink(book.host, props.player)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${book.color}66`,
|
||||
color: book.color,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{book.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{props.onAddToParlay && (
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<button onClick={props.onAddToParlay} className="btn-primary" style={{ flex: 1 }}>
|
||||
Add to Parlay
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorBlock({
|
||||
factors,
|
||||
killConditions,
|
||||
gated,
|
||||
onUpgrade,
|
||||
}: {
|
||||
factors?: FactorAnalysis;
|
||||
killConditions?: KillCondition[];
|
||||
gated: boolean;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
const hasContent = (factors && Object.values(factors).some(Boolean)) || (killConditions && killConditions.length > 0);
|
||||
if (!hasContent && !gated) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', marginTop: 16 }}>
|
||||
<SectionLabel>Factor analysis</SectionLabel>
|
||||
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
|
||||
{factors && (
|
||||
<ul style={{ display: 'grid', gap: 6, marginBottom: killConditions?.length ? 12 : 0 }}>
|
||||
{Object.entries(factors)
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.map(([k, v]) => (
|
||||
<li
|
||||
key={k}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 12 }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-tertiary)', textTransform: 'capitalize' }}>{k}</span>
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'right' }}>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{killConditions && killConditions.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,71,87,0.30)',
|
||||
background: 'rgba(255,71,87,0.08)',
|
||||
}}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--danger)', marginBottom: 6 }}>
|
||||
KILL CONDITIONS
|
||||
</div>
|
||||
{killConditions.map((k) => (
|
||||
<div key={k.code} style={{ display: 'flex', gap: 8, fontSize: 13, marginTop: 4 }}>
|
||||
<span className="mono" style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 11 }}>
|
||||
{k.code}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{k.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasContent && gated && (
|
||||
<div style={{ height: 120, background: 'var(--bg-elevated)', borderRadius: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gated && (
|
||||
<div className="tier-locked-overlay">
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Full analysis. Kill conditions. Alt lines.
|
||||
</p>
|
||||
<button onClick={onUpgrade} className="btn-primary">
|
||||
Unlock — \$14.99/mo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AltLineBlock({
|
||||
altLines,
|
||||
gated,
|
||||
currentTier,
|
||||
onUpgrade,
|
||||
}: {
|
||||
altLines?: AltLine[];
|
||||
gated: boolean;
|
||||
currentTier: Tier;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
if (!altLines || altLines.length === 0) {
|
||||
if (currentTier === 'free') return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', marginTop: 16 }}>
|
||||
<SectionLabel>Alt line ladder</SectionLabel>
|
||||
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{altLines.map((alt) => {
|
||||
const altTone = gradeTierClass(alt.grade);
|
||||
return (
|
||||
<div
|
||||
key={alt.line}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{alt.line.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
color: altTone.color,
|
||||
background: altTone.bg,
|
||||
}}
|
||||
>
|
||||
{alt.grade}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{alt.hit_rate != null ? `${Math.round(alt.hit_rate * 100)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gated && (
|
||||
<div className="tier-locked-overlay">
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Alt line ladder + Kelly sizing.
|
||||
</p>
|
||||
<button onClick={onUpgrade} className="btn-primary">
|
||||
Go Desk — $44.99/mo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredText({ text, onUpgrade }: { text: string; onUpgrade: () => void }) {
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<p className="tier-locked" style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
{text}
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="btn-primary"
|
||||
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
Unlock — \$14.99/mo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Lightweight grade pill — back-compat for callers that only
|
||||
want the colored letter (used by ledger/scan summaries)
|
||||
───────────────────────────────────────────────────────── */
|
||||
export function GradePill({ grade, confidence }: { grade: string; confidence?: number }) {
|
||||
const tone = gradeTierClass(grade);
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${tone.border}`,
|
||||
background: tone.bg,
|
||||
color: tone.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22, lineHeight: 1 }}>{grade}</span>
|
||||
{confidence != null && <span style={{ fontSize: 12, opacity: 0.85 }}>{confidence}%</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+261
-16
@@ -1,22 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { GradePill } from './GradeCard';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section className="relative min-h-[85vh] flex items-center justify-center px-4">
|
||||
<div className="max-w-3xl text-center">
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tight mb-6">
|
||||
Stop guessing.<br />
|
||||
<span className="text-[var(--accent)]">Start grading.</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-[var(--text-muted)] mb-10 max-w-xl mx-auto">
|
||||
BetonBLK scans your parlay in seconds. AI-powered prop analysis across DraftKings, FanDuel, and BetMGM.
|
||||
</p>
|
||||
<a
|
||||
href="/scan"
|
||||
className="inline-block px-8 py-4 bg-[var(--accent)] text-white font-semibold rounded-xl text-lg hover:opacity-90 transition"
|
||||
>
|
||||
Scan Your First Parlay — Free
|
||||
</a>
|
||||
<p className="mt-4 text-sm text-[var(--text-muted)]">5 free scans. No credit card required.</p>
|
||||
<section className="radial-glow diagonal-cut" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto',
|
||||
padding: '96px 24px 64px',
|
||||
display: 'grid',
|
||||
gap: 48,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
className="hero-grid"
|
||||
>
|
||||
<div className="animate-fade-up" style={{ maxWidth: 800 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.10em',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent-glow)',
|
||||
color: 'var(--grade-a)',
|
||||
marginBottom: 24,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
NBA · MLB · WNBA
|
||||
</span>
|
||||
<h1
|
||||
className="text-balance"
|
||||
style={{
|
||||
fontSize: 'clamp(36px, 6vw, 64px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.03em',
|
||||
lineHeight: 1.05,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
The books have every advantage.<br />
|
||||
<span style={{ color: 'var(--grade-a)' }}>We built this to give it back.</span>
|
||||
</h1>
|
||||
<p
|
||||
className="text-pretty"
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 32,
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
Grade your NBA, MLB, and WNBA props with intelligence the books don't want you to have.
|
||||
Forty-plus factors. Kill conditions. Alt-line ladders. The honest ledger.
|
||||
</p>
|
||||
<SportBadgeStrip />
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 24 }}>
|
||||
<a href="/signup" className="btn-primary" style={{ padding: '14px 28px', fontSize: 15 }}>
|
||||
Get Started — Free
|
||||
</a>
|
||||
<a href="/ledger" className="btn-ghost" style={{ padding: '14px 28px', fontSize: 15 }}>
|
||||
See the Ledger
|
||||
</a>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)', marginTop: 16 }}>
|
||||
5 free reads every month. No credit card. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FloatingDemoCard />
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '0 24px 32px',
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
VYNDR is an analytics tool, not a sportsbook. Gamble responsibly. 1-800-522-4700.
|
||||
</p>
|
||||
|
||||
<style jsx>{`
|
||||
@media (min-width: 960px) {
|
||||
:global(.hero-grid) {
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const SPORTS_DISPLAY = [
|
||||
{ label: 'NBA', active: true, color: '#E94B3C' },
|
||||
{ label: 'MLB', active: true, color: '#1E90FF' },
|
||||
{ label: 'WNBA', active: true, color: '#F7944A' },
|
||||
{ label: 'NFL', active: false, color: '#013369' },
|
||||
{ label: 'NHL', active: false, color: '#A0A0B0' },
|
||||
{ label: 'TENNIS', active: false, color: '#C5B358' },
|
||||
{ label: 'MMA', active: false, color: '#D4AF37' },
|
||||
{ label: 'BOXING', active: false, color: '#8B0000' },
|
||||
{ label: 'GOLF', active: false, color: '#2E7D32' },
|
||||
];
|
||||
|
||||
function SportBadgeStrip() {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
aria-label="Supported sports"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
{SPORTS_DISPLAY.map((s) => {
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: 'IBM Plex Mono, JetBrains Mono, monospace',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
padding: '5px 11px',
|
||||
borderRadius: 999,
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
return s.active ? (
|
||||
<span
|
||||
key={s.label}
|
||||
role="listitem"
|
||||
style={{
|
||||
...base,
|
||||
color: s.color,
|
||||
background: `${s.color}1A`,
|
||||
border: `1px solid ${s.color}66`,
|
||||
boxShadow: `0 0 12px ${s.color}33`,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={s.label}
|
||||
role="listitem"
|
||||
title="COMING THIS SUMMER"
|
||||
aria-label={`${s.label} — coming this summer`}
|
||||
style={{
|
||||
...base,
|
||||
color: 'var(--text-2)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingDemoCard() {
|
||||
return (
|
||||
<div
|
||||
className="animate-fade-up stagger-3"
|
||||
style={{
|
||||
position: 'relative',
|
||||
transform: 'rotate(-1deg)',
|
||||
padding: 24,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-focus)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
|
||||
maxWidth: 380,
|
||||
marginInline: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(233,75,60,0.15)',
|
||||
color: '#E94B3C',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
NBA
|
||||
</span>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
Over 26.5 points
|
||||
</p>
|
||||
</div>
|
||||
<GradePill grade="A-" confidence={73} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<Stat label="Projection" value="29.4 pts" />
|
||||
<Stat label="Edge" value="+6.2%" tone="positive" />
|
||||
</div>
|
||||
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<li style={row}>
|
||||
<span>Matchup</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span>
|
||||
</li>
|
||||
<li style={row}>
|
||||
<span>L10 form</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span>
|
||||
</li>
|
||||
<li style={row}>
|
||||
<span>Usage shift</span>
|
||||
<span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const row: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
paddingBlock: 4,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,81 @@
|
||||
const steps = [
|
||||
const STEPS = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'Build your parlay',
|
||||
description: 'Add your legs — player, stat, line, book. 2 to 12 props.',
|
||||
n: '01',
|
||||
title: 'Read a prop',
|
||||
body: 'Pick a sport. Find the player. Set the line. We grade it in seconds across forty-plus factors.',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Get your grade',
|
||||
description: 'Each leg graded A through D. Overall parlay grade with correlation checks.',
|
||||
n: '02',
|
||||
title: 'Read the grade',
|
||||
body: 'Letter grade. Projection. Confidence. Factor breakdown. Kill conditions. Alt line ladder. The whole picture.',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'See the edge',
|
||||
description: 'Season averages, recent form, situational splits, cross-book line comparison. Every factor explained.',
|
||||
n: '03',
|
||||
title: 'Make the call',
|
||||
body: 'Take the prop, walk away, or shop the alt line. Either way, you decided with intelligence — not vibes.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HowItWorks() {
|
||||
return (
|
||||
<section className="py-24 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-16">How It Works</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{steps.map((step) => (
|
||||
<div key={step.number} className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="font-mono text-[var(--accent)] text-sm font-bold mb-3">{step.number}</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{step.title}</h3>
|
||||
<p className="text-[var(--text-muted)] text-sm leading-relaxed">{step.description}</p>
|
||||
<section
|
||||
style={{
|
||||
padding: '96px 24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
|
||||
<h2
|
||||
className="text-balance"
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 16 }}
|
||||
>
|
||||
How it works.
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
||||
Three steps. No tout picks. No black box.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="hiw-grid" style={{ display: 'grid', gap: 24, position: 'relative' }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div
|
||||
key={s.n}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${i + 1}`}
|
||||
style={{
|
||||
padding: 32,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--grade-a)',
|
||||
letterSpacing: '0.10em',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
STEP {s.n}
|
||||
</div>
|
||||
<h3 style={{ fontSize: 22, fontWeight: 700, marginBottom: 12, letterSpacing: '-0.01em' }}>{s.title}</h3>
|
||||
<p style={{ fontSize: 15, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
:global(.hiw-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:global(.hiw-grid) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// PWA install banner. Shown only after the user has completed ≥2 Reads — we
|
||||
// don't want to nag visitors before they've seen the product work. Trigger
|
||||
// counter is incremented elsewhere via incrementReadCount() in lib/reads.ts.
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
const READS_KEY = 'vyndr_reads_completed';
|
||||
const DISMISSED_KEY = 'vyndr_install_dismissed';
|
||||
const REQUIRED_READS = 2;
|
||||
const DISMISSAL_COOLDOWN_DAYS = 7;
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return true;
|
||||
// iOS Safari exposes navigator.standalone only for installed PWAs.
|
||||
const nav = window.navigator as Navigator & { standalone?: boolean };
|
||||
return nav.standalone === true;
|
||||
}
|
||||
|
||||
function isIOS(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = window.navigator.userAgent;
|
||||
return /iPad|iPhone|iPod/.test(ua) && !(window as unknown as { MSStream?: unknown }).MSStream;
|
||||
}
|
||||
|
||||
function readsCompleted(): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
const raw = window.localStorage.getItem(READS_KEY);
|
||||
return raw ? parseInt(raw, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
function dismissedRecently(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const raw = window.localStorage.getItem(DISMISSED_KEY);
|
||||
if (!raw) return false;
|
||||
const ts = parseInt(raw, 10);
|
||||
if (!ts) return false;
|
||||
const ageDays = (Date.now() - ts) / (1000 * 60 * 60 * 24);
|
||||
return ageDays < DISMISSAL_COOLDOWN_DAYS;
|
||||
}
|
||||
|
||||
export default function InstallPrompt() {
|
||||
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [iosHint, setIosHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStandalone()) return;
|
||||
if (readsCompleted() < REQUIRED_READS) return;
|
||||
if (dismissedRecently()) return;
|
||||
|
||||
if (isIOS()) {
|
||||
// iOS doesn't fire beforeinstallprompt — show manual instructions.
|
||||
setIosHint(true);
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const onBeforeInstall = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferred(e as BeforeInstallPromptEvent);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
||||
return () => window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferred) return;
|
||||
await deferred.prompt();
|
||||
const choice = await deferred.userChoice;
|
||||
if (choice.outcome === 'dismissed') {
|
||||
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
|
||||
}
|
||||
setVisible(false);
|
||||
setDeferred(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Install VYNDR"
|
||||
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Install VYNDR
|
||||
</div>
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{iosHint
|
||||
? 'Tap the Share button, then "Add to Home Screen" for instant access.'
|
||||
: 'Add VYNDR to your home screen for instant access.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss install prompt"
|
||||
className="rounded p-1 text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{!iosHint && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold"
|
||||
style={{
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-0)',
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface LiveProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: string;
|
||||
grade: string;
|
||||
sport: string;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
function gradeColor(grade: string): string {
|
||||
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||
if (g === 'A') return 'var(--grade-a)';
|
||||
if (g === 'B') return 'var(--grade-b)';
|
||||
if (g === 'C') return 'var(--grade-c)';
|
||||
return 'var(--grade-d)';
|
||||
}
|
||||
|
||||
export default function LivePropsStrip() {
|
||||
const [props, setProps] = useState<LiveProp[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/props/live');
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setError(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setProps(data.slice(0, 12));
|
||||
setError(false);
|
||||
} else {
|
||||
setProps([]);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
}
|
||||
}
|
||||
load();
|
||||
const id = setInterval(load, 60_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Loading / fallback state
|
||||
if (props === null) return null;
|
||||
|
||||
if (error || props.length === 0) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
padding: '24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="mono"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
TONIGHT'S GRADES LOAD AT 5 PM ET
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate for seamless ticker scroll
|
||||
const ticker = [...props, ...props];
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
padding: '20px 0',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="animate-ticker" style={{ display: 'flex', gap: 16, whiteSpace: 'nowrap', width: 'max-content' }}>
|
||||
{ticker.map((p, i) => (
|
||||
<div
|
||||
key={`${p.player}-${i}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
color: SPORT_COLOR[p.sport] || 'var(--text-secondary)',
|
||||
background: `${SPORT_COLOR[p.sport] || 'var(--text-secondary)'}1F`,
|
||||
}}
|
||||
>
|
||||
{p.sport}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{p.player}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{p.direction} {p.line} {p.stat}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: gradeColor(p.grade),
|
||||
}}
|
||||
>
|
||||
{p.grade}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
|
||||
// Mounts at the root layout. Checks Supabase's AAL (Authenticator Assurance
|
||||
// Level) after every auth state change. If the user has MFA enrolled but
|
||||
// their session is still at aal1, we block the UI with a code challenge
|
||||
// until they reach aal2. Until verified, they can't see paid features.
|
||||
|
||||
type ChallengeState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'needed'; factorId: string }
|
||||
| { status: 'verifying'; factorId: string; challengeId: string };
|
||||
|
||||
export default function MFAChallenge() {
|
||||
const { user, session } = useAuth();
|
||||
const [state, setState] = useState<ChallengeState>({ status: 'idle' });
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const evaluate = useCallback(async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase || !user) {
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const aal = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (aal.error) return;
|
||||
const { currentLevel, nextLevel } = aal.data;
|
||||
if (currentLevel === 'aal1' && nextLevel === 'aal2') {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
|
||||
if (factor) {
|
||||
setState({ status: 'needed', factorId: factor.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState({ status: 'idle' });
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
void evaluate();
|
||||
}, [evaluate, session?.access_token]);
|
||||
|
||||
const issueChallenge = async () => {
|
||||
if (state.status !== 'needed') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
const { data, error: cErr } = await supabase.auth.mfa.challenge({ factorId: state.factorId });
|
||||
if (cErr || !data) {
|
||||
setError(cErr?.message ?? 'Could not start MFA challenge.');
|
||||
return;
|
||||
}
|
||||
setState({ status: 'verifying', factorId: state.factorId, challengeId: data.id });
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (state.status !== 'verifying') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await supabase.auth.mfa.verify({
|
||||
factorId: state.factorId,
|
||||
challengeId: state.challengeId,
|
||||
code,
|
||||
});
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setCode('');
|
||||
setState({ status: 'idle' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === 'idle') return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Two-factor authentication required"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border p-5"
|
||||
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Two-factor required
|
||||
</h2>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Enter the 6-digit code from your authenticator app.
|
||||
</p>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="mt-4 w-full rounded border px-3 py-2 text-center text-lg tracking-widest"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="123456"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs" style={{ color: 'var(--grade-d)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{state.status === 'needed' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={issueChallenge}
|
||||
className="w-full rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={code.length !== 6 || submitting}
|
||||
className="w-full rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
{submitting ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// One-time nag for paid users who haven't been told about MFA yet.
|
||||
// The actual enrollment happens on /settings/security — this modal just
|
||||
// directs them there. We mark prompted=true regardless of action so we
|
||||
// don't pester users who explicitly chose "later".
|
||||
|
||||
export default function MFAPrompt() {
|
||||
const { user, tier, profile, markMFAPrompted } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !profile) return;
|
||||
if (tier === 'free') return;
|
||||
if (profile.mfa_setup_prompted) return;
|
||||
setOpen(true);
|
||||
}, [user, tier, profile]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleLater = async () => {
|
||||
setOpen(false);
|
||||
await markMFAPrompted();
|
||||
};
|
||||
|
||||
const tierLabel = tier === 'desk' ? 'Desk' : 'Analyst';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Secure your account"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border p-5"
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Secure your {tierLabel} account</h2>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Two-factor authentication takes 60 seconds and protects your subscription, billing details,
|
||||
and Ledger history from password-only attacks.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLater}
|
||||
className="rounded border px-4 py-2 text-sm font-semibold"
|
||||
style={{ borderColor: 'var(--border-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Remind me later
|
||||
</button>
|
||||
<Link
|
||||
href="/settings/security"
|
||||
onClick={() => {
|
||||
void markMFAPrompted();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Set up now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: 'Read', href: '/scan' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'Ledger', href: '/ledger' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const { user, tier, scansRemaining, signOut } = useAuth();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
height: 64,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'rgba(10, 10, 15, 0.85)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1280,
|
||||
margin: '0 auto',
|
||||
padding: '0 24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={22} />
|
||||
</a>
|
||||
|
||||
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
||||
{NAV_LINKS.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 200ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{user ? (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{scansRemaining != null && tier === 'free' && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{scansRemaining}/5 reads · MO
|
||||
</span>
|
||||
)}
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-focus)',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 'calc(100% + 8px)',
|
||||
minWidth: 220,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Signed in as</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--grade-a)', textTransform: 'uppercase' }}>
|
||||
{tier} tier
|
||||
</div>
|
||||
</div>
|
||||
{tier === 'free' && (
|
||||
<a
|
||||
href="/#pricing"
|
||||
role="menuitem"
|
||||
style={{ display: 'block', padding: '10px 12px', fontSize: 13, color: 'var(--text-primary)', textDecoration: 'none' }}
|
||||
>
|
||||
Upgrade — $14.99/mo
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
void signOut();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
Log In
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="nav-mobile-toggle"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{mobileOpen ? '×' : '≡'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="nav-mobile-panel"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-primary)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{NAV_LINKS.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
{user ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
void signOut();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
fontSize: 15,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/login"
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 8, padding: 12 }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Log In
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@media (min-width: 768px) {
|
||||
:global(.nav-desktop) {
|
||||
display: flex !important;
|
||||
}
|
||||
:global(.nav-mobile-toggle) {
|
||||
display: none !important;
|
||||
}
|
||||
:global(.nav-mobile-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type NotificationType =
|
||||
| 'rare_grade'
|
||||
| 'cascade'
|
||||
| 'steam'
|
||||
| 'morning_results'
|
||||
| 'line_movement'
|
||||
| 'injury'
|
||||
| 'system';
|
||||
|
||||
type Notification = {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
link?: string | null;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<NotificationType, string> = {
|
||||
rare_grade: 'A+ ALERT',
|
||||
cascade: 'CASCADE',
|
||||
steam: 'STEAM',
|
||||
morning_results: 'RESULTS',
|
||||
line_movement: 'LINE',
|
||||
injury: 'INJURY',
|
||||
system: 'SYSTEM',
|
||||
};
|
||||
|
||||
const TYPE_TINT: Record<NotificationType, string> = {
|
||||
rare_grade: 'var(--grade-aplus)',
|
||||
cascade: 'var(--grade-c)',
|
||||
steam: 'var(--grade-c)',
|
||||
morning_results: 'var(--grade-b)',
|
||||
line_movement: 'var(--grade-b)',
|
||||
injury: 'var(--grade-d)',
|
||||
system: 'var(--text-1)',
|
||||
};
|
||||
|
||||
// Mock items used until /api/notifications is wired. The shape matches the
|
||||
// future Supabase row exactly so the dropdown won't change when real data lands.
|
||||
const MOCK: Notification[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
type: 'rare_grade',
|
||||
title: 'Jokic Points Over 25.5 graded A+',
|
||||
body: 'Rare grade tonight. Phosphor confirmed.',
|
||||
link: '/dashboard',
|
||||
read: false,
|
||||
created_at: new Date(Date.now() - 8 * 60_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
type: 'cascade',
|
||||
title: 'Murray OUT → Jokic usage +3.2%',
|
||||
body: 'Cascade recalibrated 4 props across DEN.',
|
||||
link: '/dashboard',
|
||||
read: false,
|
||||
created_at: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'mock-3',
|
||||
type: 'morning_results',
|
||||
title: 'Last night: 2 of 3 graded A+ hit',
|
||||
body: 'Brunson, Edwards landed. Wilson missed by 1.',
|
||||
link: '/ledger',
|
||||
read: true,
|
||||
created_at: new Date(Date.now() - 13 * 60 * 60_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const diff = (Date.now() - d.getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86_400) return `${Math.floor(diff / 3600)}h`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<Notification[]>([]);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notifications', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('not-yet');
|
||||
const data = (await res.json()) as { notifications?: Notification[] };
|
||||
if (alive) setItems(Array.isArray(data.notifications) ? data.notifications : MOCK);
|
||||
} catch {
|
||||
if (alive) setItems(MOCK);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const unread = useMemo(() => items.filter((n) => !n.read).length, [items]);
|
||||
|
||||
const markAllRead = () => {
|
||||
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
void fetch('/api/notifications/read-all', { method: 'POST' }).catch(() => {});
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={`Notifications${unread ? `, ${unread} unread` : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-1)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<BellIcon />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
padding: '0 4px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--grade-a)',
|
||||
color: '#062b22',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)',
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
}}
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 'calc(100% + 8px)',
|
||||
width: 320,
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
padding: 8,
|
||||
zIndex: 60,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 8px 10px' }}>
|
||||
<span className="lbl" style={{ color: 'var(--text-1)' }}>ALERTS</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--grade-a)', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div style={{ padding: '24px 12px', textAlign: 'center', color: 'var(--text-1)' }}>
|
||||
<p style={{ fontSize: 13 }}>No new alerts.</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 4 }}>
|
||||
We'll notify you when something moves.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<a
|
||||
href={n.link || '#'}
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 4,
|
||||
padding: '10px 10px',
|
||||
borderRadius: 8,
|
||||
background: n.read ? 'transparent' : 'rgba(0, 212, 160, 0.05)',
|
||||
borderLeft: `2px solid ${n.read ? 'var(--border)' : TYPE_TINT[n.type]}`,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
fontSize: 10,
|
||||
letterSpacing: '0.08em',
|
||||
color: TYPE_TINT[n.type],
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{TYPE_LABEL[n.type]}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{fmtTime(n.created_at)}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-0)', lineHeight: 1.35 }}>
|
||||
{n.title}
|
||||
</span>
|
||||
{n.body ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-1)', lineHeight: 1.4 }}>{n.body}</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 9a6 6 0 1112 0c0 4 2 5 2 5H4s2-1 2-5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M10 18a2 2 0 004 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext';
|
||||
import { GradePill } from './GradeCard';
|
||||
import { trackParlayBuilt } from '@/lib/analytics';
|
||||
|
||||
interface ParlayGradeResponse {
|
||||
parlay_grade: string;
|
||||
parlay_confidence: number;
|
||||
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
|
||||
decimal_odds?: number;
|
||||
}
|
||||
|
||||
export default function ParlayTray() {
|
||||
const { legs, isOpen, close, removeLeg, clear } = useParlay();
|
||||
const [grading, setGrading] = useState(false);
|
||||
const [parlayResult, setParlayResult] = useState<ParlayGradeResponse | null>(null);
|
||||
|
||||
// Reset the parlay grade whenever the leg set changes
|
||||
useEffect(() => {
|
||||
setParlayResult(null);
|
||||
}, [legs]);
|
||||
|
||||
const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]);
|
||||
|
||||
const gradeParlay = async () => {
|
||||
if (legs.length < 2) return;
|
||||
setGrading(true);
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/parlay/grade', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legs: legs.map((l) => ({
|
||||
sport: l.sport,
|
||||
player: l.player,
|
||||
stat_type: l.stat,
|
||||
line: l.line,
|
||||
direction: l.direction,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as ParlayGradeResponse;
|
||||
if (res.ok) {
|
||||
setParlayResult(data);
|
||||
trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade });
|
||||
}
|
||||
} finally {
|
||||
setGrading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Parlay tray"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label="Close parlay tray"
|
||||
onClick={close}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<section
|
||||
className="surface-elevated diagonal-cut animate-fade-up"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: 560,
|
||||
maxHeight: '85vh',
|
||||
margin: '0 auto',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Parlay tray</h2>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
{legs.length} LEG{legs.length === 1 ? '' : 'S'} · {sports.join(' · ') || 'ADD A LEG'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={close} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 12 }}>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{legs.length === 0 ? (
|
||||
<EmptyTrayCopy />
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{legs.map((l) => (
|
||||
<LegRow key={l.id} leg={l} onRemove={() => removeLeg(l.id)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{parlayResult && (
|
||||
<div
|
||||
className="surface diagonal-cut"
|
||||
style={{
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--border-focus)',
|
||||
}}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
PARLAY GRADE
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
|
||||
<GradePill grade={parlayResult.parlay_grade} confidence={parlayResult.parlay_confidence} />
|
||||
</div>
|
||||
{parlayResult.correlation_flags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
textAlign: 'left',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,179,71,0.10)',
|
||||
border: '1px solid rgba(255,179,71,0.30)',
|
||||
}}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--grade-c)', marginBottom: 4 }}>
|
||||
CORRELATION WARNINGS
|
||||
</p>
|
||||
{parlayResult.correlation_flags.map((f, i) => (
|
||||
<p key={i} style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{f.detail}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{legs.length > 0 && (
|
||||
<footer style={{ display: 'grid', gap: 8 }}>
|
||||
<button
|
||||
onClick={gradeParlay}
|
||||
disabled={legs.length < 2 || grading}
|
||||
className={grading ? 'shimmer-loading' : 'btn-primary'}
|
||||
style={{ padding: 14, fontWeight: 600, fontSize: 14, border: 'none', borderRadius: 12, color: 'var(--text-primary)', cursor: legs.length < 2 ? 'not-allowed' : 'pointer', opacity: legs.length < 2 ? 0.4 : 1 }}
|
||||
>
|
||||
{grading ? 'Running correlation analysis…' : legs.length < 2 ? 'Add 2+ legs to grade' : 'Grade parlay'}
|
||||
</button>
|
||||
<button onClick={clear} className="btn-ghost" style={{ padding: 12, fontSize: 13 }}>
|
||||
Clear tray
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTrayCopy() {
|
||||
return (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
NO LEGS YET
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: 'var(--text-secondary)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
Read a prop, hit <strong>Add to Parlay</strong>, and we'll build the slip here.
|
||||
We grade overall correlation and surface the legs that secretly fight each other.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) {
|
||||
return (
|
||||
<li
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{leg.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<GradePill grade={leg.grade} />
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${leg.player}`}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '4px 10px', fontSize: 11 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type PlayerResult = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
headshot_url?: string;
|
||||
};
|
||||
|
||||
export type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
type Props = {
|
||||
sport: Sport;
|
||||
gameId?: string;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
onSelect: (player: PlayerResult) => void;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const SPORT_TINT: Record<Sport, string> = {
|
||||
NBA: 'var(--nba)',
|
||||
MLB: 'var(--mlb)',
|
||||
WNBA: 'var(--wnba)',
|
||||
};
|
||||
|
||||
export default function PlayerSearch({
|
||||
sport,
|
||||
gameId,
|
||||
placeholder = 'Search players…',
|
||||
initialValue = '',
|
||||
onSelect,
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
const [results, setResults] = useState<PlayerResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputId = useId();
|
||||
const listboxId = `${inputId}-listbox`;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Debounced fetch
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ sport, q });
|
||||
if (gameId) params.set('game_id', gameId);
|
||||
const res = await fetch(`/api/players/search?${params.toString()}`, { signal: ctrl.signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!ctrl.signal.aborted) {
|
||||
setResults(Array.isArray(data?.players) ? data.players.slice(0, 5) : []);
|
||||
setHighlight(0);
|
||||
}
|
||||
} catch {
|
||||
if (!ctrl.signal.aborted) setResults([]);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, 220);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [query, sport, gameId]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
const noResults = useMemo(
|
||||
() => !loading && results !== null && results.length === 0 && query.trim().length >= 2,
|
||||
[loading, results, query],
|
||||
);
|
||||
|
||||
const choose = (p: PlayerResult) => {
|
||||
onSelect(p);
|
||||
setQuery(p.full_name);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!results || results.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHighlight((h) => Math.min(results.length - 1, h + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(0, h - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const sel = results[highlight];
|
||||
if (sel) choose(sel);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={inputId}
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open && (loading || !!results?.length || noResults)}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={results && results[highlight] ? `${inputId}-opt-${highlight}` : undefined}
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
className="input-field"
|
||||
value={query}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{open && (loading || results !== null) && (
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 30,
|
||||
margin: 0,
|
||||
padding: 4,
|
||||
listStyle: 'none',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<li style={{ padding: 12 }}>
|
||||
<span className="lbl" style={{ color: 'var(--text-1)' }}>SEARCHING…</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!loading && noResults && (
|
||||
<li style={{ padding: 12 }}>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-0)', margin: 0 }}>
|
||||
No players found for "{query}".
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-1)', margin: '4px 0 0' }}>Check spelling.</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!loading && results && results.map((p, i) => {
|
||||
const active = i === highlight;
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
id={`${inputId}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseDown={(e) => { e.preventDefault(); choose(p); }}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: active ? 'var(--bg-2)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 999,
|
||||
background: 'var(--bg-3)',
|
||||
border: `1px solid ${SPORT_TINT[sport]}`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono, "IBM Plex Mono")',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-1)',
|
||||
}}
|
||||
>
|
||||
{p.full_name.split(' ').map((n) => n[0]).slice(0, 2).join('')}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text-0)', fontSize: 14, fontWeight: 600 }}>
|
||||
{p.full_name}
|
||||
</span>
|
||||
{p.team ? (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.team}</span>
|
||||
) : null}
|
||||
<span
|
||||
className="pill"
|
||||
style={{
|
||||
color: SPORT_TINT[sport],
|
||||
background: 'transparent',
|
||||
border: `1px solid ${SPORT_TINT[sport]}`,
|
||||
}}
|
||||
>
|
||||
{sport}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { initAnalytics, trackPageView } from '@/lib/analytics';
|
||||
|
||||
export default function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
initAnalytics();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname) trackPageView(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
+151
-83
@@ -1,120 +1,188 @@
|
||||
const tiers = [
|
||||
'use client';
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: '$0',
|
||||
founderPrice: null,
|
||||
period: '',
|
||||
cta: 'Get Started',
|
||||
cadence: '/mo',
|
||||
headline: 'Try the model. No card required.',
|
||||
cta: 'Start Free',
|
||||
ctaHref: '/signup',
|
||||
highlight: false,
|
||||
features: [
|
||||
'5 scans per month',
|
||||
'View line movements',
|
||||
'Basic prop grades',
|
||||
'5 reads per month',
|
||||
'Grade letter + projection',
|
||||
'Cross-book line comparison',
|
||||
'Confidence indicator',
|
||||
],
|
||||
unavailable: ['Bet tracking', 'Cascade alerts', 'Performance analytics'],
|
||||
locked: [
|
||||
'Factor analysis (blurred)',
|
||||
'Kill conditions (blurred)',
|
||||
'Alt line ladder (locked)',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
id: 'analyst',
|
||||
name: 'Analyst',
|
||||
price: '$19.99',
|
||||
founderPrice: '$14.99',
|
||||
period: '/mo',
|
||||
cta: 'Subscribe',
|
||||
ctaHref: '/api/stripe/checkout?tier=analyst',
|
||||
highlight: true,
|
||||
price: '$14.99',
|
||||
originalPrice: '$24.99',
|
||||
cadence: '/mo',
|
||||
badge: 'Founder Access',
|
||||
headline: 'The full intelligence layer.',
|
||||
cta: 'Lock Founder Price',
|
||||
ctaHref: '/api/checkout?tier=analyst',
|
||||
features: [
|
||||
'Unlimited scans',
|
||||
'Line movement alerts',
|
||||
'Bet tracking',
|
||||
'Cascade alerts',
|
||||
'Basic performance analytics',
|
||||
'Unlimited reads',
|
||||
'Full factor analysis (40+ signals)',
|
||||
'Kill conditions surfaced inline',
|
||||
'Cascade alerts when lineups shift',
|
||||
'Parlay leg history with grades',
|
||||
'Sportsbook deep links',
|
||||
],
|
||||
unavailable: ['Priority alerts', 'Behavioral patterns'],
|
||||
locked: [
|
||||
'Alt line ladder (Desk only)',
|
||||
'Kelly sizing (Desk only)',
|
||||
],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
id: 'desk',
|
||||
name: 'Desk',
|
||||
price: '$49.99',
|
||||
founderPrice: '$34.99',
|
||||
period: '/mo',
|
||||
cta: 'Subscribe',
|
||||
ctaHref: '/api/stripe/checkout?tier=desk',
|
||||
highlight: false,
|
||||
price: '$44.99',
|
||||
originalPrice: '$49.99',
|
||||
cadence: '/mo',
|
||||
headline: 'Everything. The professional setup.',
|
||||
cta: 'Go Desk',
|
||||
ctaHref: '/api/checkout?tier=desk',
|
||||
features: [
|
||||
'Unlimited scans',
|
||||
'Line movement + priority alerts',
|
||||
'Full bet tracking',
|
||||
'Priority cascade alerts',
|
||||
'Full performance analytics',
|
||||
'Behavioral pattern insights',
|
||||
'Everything in Analyst',
|
||||
'Alt line ladder + edge ranking',
|
||||
'Quarter-Kelly sizing recommendations',
|
||||
'Real-time intelligence feed',
|
||||
'Parlay correlation analysis (phi)',
|
||||
'Consensus vs model comparison',
|
||||
'API access (coming Q3)',
|
||||
],
|
||||
unavailable: [],
|
||||
locked: [],
|
||||
highlight: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section className="py-24 px-4" id="pricing">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-4">Simple Pricing</h2>
|
||||
<p className="text-[var(--text-muted)] text-center mb-16">Start free. Upgrade when you're ready.</p>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className={`relative p-6 rounded-2xl border ${
|
||||
tier.highlight
|
||||
? 'border-[var(--accent)] bg-[var(--accent)]/5'
|
||||
: 'border-[var(--border)] bg-[var(--card)]'
|
||||
}`}
|
||||
<section
|
||||
id="pricing"
|
||||
style={{
|
||||
padding: '96px 24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
|
||||
<h2
|
||||
className="text-balance"
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 16 }}
|
||||
>
|
||||
Pricing built for bettors. Not for SaaS investors.
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
||||
First 100 users lock $14.99/mo for life. This price dies at user 101.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
|
||||
{TIERS.map((tier, i) => (
|
||||
<article
|
||||
key={tier.id}
|
||||
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
|
||||
style={{
|
||||
padding: 32,
|
||||
position: 'relative',
|
||||
border: tier.highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
background: tier.highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
boxShadow: tier.highlight ? '0 16px 48px var(--accent-glow)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tier.founderPrice && (
|
||||
<div className="absolute -top-3 left-4 px-3 py-0.5 bg-[var(--accent)] text-white text-xs font-mono font-bold rounded-full">
|
||||
Founder Rate — Locked for Life
|
||||
{tier.badge && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: 24,
|
||||
padding: '4px 12px',
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-primary)',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.08em',
|
||||
borderRadius: 999,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl font-bold mt-2 mb-1">{tier.name}</h3>
|
||||
<div className="flex items-baseline gap-1 mb-6">
|
||||
{tier.founderPrice ? (
|
||||
<>
|
||||
<span className="text-3xl font-bold font-mono">{tier.founderPrice}</span>
|
||||
<span className="text-[var(--text-muted)] text-sm">{tier.period}</span>
|
||||
<span className="ml-2 text-sm text-[var(--text-muted)] line-through">{tier.price}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold font-mono">{tier.price}</span>
|
||||
<span className="text-[var(--text-muted)] text-sm">{tier.period}</span>
|
||||
</>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 40, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.03em' }}>
|
||||
{tier.price}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 14 }}>{tier.cadence}</span>
|
||||
{tier.originalPrice && (
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||||
{tier.originalPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-2 mb-8">
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-[var(--grade-a)] mt-0.5">+</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
{tier.unavailable.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-[var(--text-muted)]">
|
||||
<span className="mt-0.5">-</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, minHeight: 42 }}>
|
||||
{tier.headline}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={tier.ctaHref}
|
||||
className={`block text-center py-3 rounded-xl font-medium transition ${
|
||||
tier.highlight
|
||||
? 'bg-[var(--accent)] text-white hover:opacity-90'
|
||||
: 'bg-[var(--border)] text-white hover:bg-[var(--text-muted)]/20'
|
||||
}`}
|
||||
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ width: '100%', padding: 14, marginBottom: 24 }}
|
||||
>
|
||||
{tier.cta}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul style={{ display: 'grid', gap: 10 }}>
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--grade-a)', fontWeight: 700 }} aria-hidden>+</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
{tier.locked.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14, color: 'var(--text-tertiary)' }}>
|
||||
<span aria-hidden>—</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
||||
Cancel anytime. No contracts. Card or Apple Pay or Google Pay — payments processed by NexaPay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
:global(.pricing-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:global(.pricing-grid) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// Push opt-in banner. Same gating as InstallPrompt: only after 2 Reads.
|
||||
// We treat the result tri-state:
|
||||
// granted → POST the PushSubscription to /api/push/subscribe
|
||||
// denied → remember it; never ask again
|
||||
// default → user dismissed; ask again next session
|
||||
|
||||
const READS_KEY = 'vyndr_reads_completed';
|
||||
const ASKED_KEY = 'vyndr_push_asked';
|
||||
const DENIED_KEY = 'vyndr_push_denied';
|
||||
const REQUIRED_READS = 2;
|
||||
|
||||
function readsCompleted(): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
const raw = window.localStorage.getItem(READS_KEY);
|
||||
return raw ? parseInt(raw, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = window.atob(base64);
|
||||
const buffer = new ArrayBuffer(raw.length);
|
||||
const view = new Uint8Array(buffer);
|
||||
for (let i = 0; i < raw.length; i += 1) view[i] = raw.charCodeAt(i);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export default function PushPrompt() {
|
||||
const { user } = useAuth();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
|
||||
if (window.localStorage.getItem(DENIED_KEY)) return;
|
||||
if (window.sessionStorage.getItem(ASKED_KEY)) return;
|
||||
if (readsCompleted() < REQUIRED_READS) return;
|
||||
setVisible(true);
|
||||
}, [user]);
|
||||
|
||||
const handleEnable = async () => {
|
||||
setBusy(true);
|
||||
window.sessionStorage.setItem(ASKED_KEY, '1');
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'denied') {
|
||||
window.localStorage.setItem(DENIED_KEY, '1');
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
if (permission !== 'granted') {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
if (!vapidKey) {
|
||||
console.warn('[push] NEXT_PUBLIC_VAPID_PUBLIC_KEY not set');
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: base64UrlToArrayBuffer(vapidKey),
|
||||
});
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription }),
|
||||
});
|
||||
setVisible(false);
|
||||
} catch (err) {
|
||||
console.warn('[push] subscribe failed:', err);
|
||||
setVisible(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
window.sessionStorage.setItem(ASKED_KEY, '1');
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Enable notifications"
|
||||
className="fixed bottom-24 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">Get notified on cascades + A+ alerts</div>
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
We'll ping you when a prop drops to A+, a cascade triggers, or your reads resolve.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss notification prompt"
|
||||
className="rounded p-1 text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnable}
|
||||
disabled={busy}
|
||||
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
{busy ? 'Enabling…' : 'Enable notifications'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { trackShareCardGenerated } from '@/lib/analytics';
|
||||
|
||||
interface ShareCardProps {
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
sampleSize?: number;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<ShareCardProps['sport'], string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
function gradeColor(grade: string): string {
|
||||
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||
if (g === 'A') return '#00C896';
|
||||
if (g === 'B') return '#4A9EFF';
|
||||
if (g === 'C') return '#FFB347';
|
||||
return '#FF6B6B';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a 1200x630 OG-shaped share image into a hidden canvas, then
|
||||
* provides Download + Copy actions. Intentionally hides the analysis —
|
||||
* shares the GRADE only, which is what drives traffic back to the site.
|
||||
*/
|
||||
export function useShareCard(props: ShareCardProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const ensureCanvas = (): HTMLCanvasElement => {
|
||||
if (canvasRef.current) return canvasRef.current;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 1200;
|
||||
c.height = 630;
|
||||
canvasRef.current = c;
|
||||
return c;
|
||||
};
|
||||
|
||||
const renderToCanvas = async (): Promise<HTMLCanvasElement> => {
|
||||
const c = ensureCanvas();
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) throw new Error('No 2D context.');
|
||||
|
||||
// Background — obsidian with diagonal accent
|
||||
ctx.fillStyle = '#0A0A0F';
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
|
||||
// Diagonal gradient overlay
|
||||
const g = ctx.createLinearGradient(0, 0, c.width, c.height);
|
||||
g.addColorStop(0, 'rgba(26,74,58,0.20)');
|
||||
g.addColorStop(1, 'rgba(0,200,150,0.02)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
|
||||
// VYNDR wordmark top-left
|
||||
ctx.fillStyle = '#F0F0F5';
|
||||
ctx.font = '800 38px "JetBrains Mono", "SF Mono", ui-monospace, monospace';
|
||||
ctx.fillText('VYND', 64, 96);
|
||||
ctx.fillStyle = '#00D4A0';
|
||||
ctx.fillText('R', 64 + ctx.measureText('VYND').width, 96);
|
||||
|
||||
// Sport badge
|
||||
const sportColor = SPORT_COLOR[props.sport];
|
||||
ctx.font = '700 16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = sportColor;
|
||||
ctx.fillText(props.sport, 64, 220);
|
||||
|
||||
// Player name (large)
|
||||
ctx.fillStyle = '#F0F0F5';
|
||||
ctx.font = '700 72px "Instrument Sans", system-ui, sans-serif';
|
||||
wrapText(ctx, props.player, 64, 300, 780, 80);
|
||||
|
||||
// Prop line (mono)
|
||||
ctx.fillStyle = '#8A8A9A';
|
||||
ctx.font = '500 28px "JetBrains Mono", monospace';
|
||||
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
ctx.fillText(
|
||||
`${cap(props.direction)} ${props.line} ${props.stat.replace(/_/g, ' ')}`,
|
||||
64,
|
||||
420,
|
||||
);
|
||||
|
||||
// Grade letter (huge, colored)
|
||||
const gc = gradeColor(props.grade);
|
||||
ctx.fillStyle = gc;
|
||||
ctx.font = '800 240px "JetBrains Mono", monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(props.grade || '—', c.width - 64, 380);
|
||||
|
||||
// Glow ring behind the grade
|
||||
ctx.shadowColor = gc;
|
||||
ctx.shadowBlur = 60;
|
||||
ctx.fillText(props.grade || '—', c.width - 64, 380);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Projection (small, beneath player line)
|
||||
if (props.projection != null) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#5A5A6A';
|
||||
ctx.font = '500 22px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Projection ${props.projection.toFixed(1)}`, 64, 470);
|
||||
}
|
||||
|
||||
// Footer — watermark
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#5A5A6A';
|
||||
ctx.font = '500 18px "JetBrains Mono", monospace';
|
||||
ctx.fillText('vyndr.app', 64, 580);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = '#5A5A6A';
|
||||
ctx.fillText('Built in Detroit.', c.width - 64, 580);
|
||||
|
||||
return c;
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
const c = await renderToCanvas();
|
||||
c.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `vyndr-${props.player.replace(/\W+/g, '-')}-${props.grade}.png`.toLowerCase();
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
const copyToClipboard = async (): Promise<boolean> => {
|
||||
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) return false;
|
||||
const c = await renderToCanvas();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
c.toBlob(async (blob) => {
|
||||
if (!blob) return resolve(false);
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
};
|
||||
|
||||
return { download, copyToClipboard };
|
||||
}
|
||||
|
||||
function wrapText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
) {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
const m = ctx.measureText(test);
|
||||
if (m.width > maxWidth && line) {
|
||||
ctx.fillText(line, x, y);
|
||||
line = word;
|
||||
y += lineHeight;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) ctx.fillText(line, x, y);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export type Sport = 'NBA' | 'MLB';
|
||||
|
||||
export interface OddsLine {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: string;
|
||||
book: string;
|
||||
}
|
||||
|
||||
interface SimplifiedSelectorProps {
|
||||
onScan: (leg: { player: string; stat_type: string; line: number; direction: string; sport: Sport }) => void;
|
||||
scanning?: boolean;
|
||||
oddsApiUrl?: string;
|
||||
nbaServiceUrl?: string;
|
||||
}
|
||||
|
||||
const NBA_STATS = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
|
||||
const MLB_STATS = [
|
||||
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
||||
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
|
||||
];
|
||||
|
||||
export default function SimplifiedSelector({
|
||||
onScan,
|
||||
scanning = false,
|
||||
oddsApiUrl,
|
||||
nbaServiceUrl,
|
||||
}: SimplifiedSelectorProps) {
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [selectedPlayer, setSelectedPlayer] = useState('');
|
||||
const [statType, setStatType] = useState('');
|
||||
const [line, setLine] = useState<number | ''>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [playerOdds, setPlayerOdds] = useState<OddsLine[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const stats = sport === 'NBA' ? NBA_STATS : MLB_STATS;
|
||||
const apiBase = oddsApiUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const nbaBase = nbaServiceUrl || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
|
||||
|
||||
// Reset when sport changes
|
||||
useEffect(() => {
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setStatType('');
|
||||
setLine('');
|
||||
setPlayerOdds([]);
|
||||
setSuggestions([]);
|
||||
}, [sport]);
|
||||
|
||||
// Fetch player suggestions
|
||||
const searchPlayer = useCallback(
|
||||
async (name: string) => {
|
||||
if (name.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${nbaBase}/players/search?name=${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
setSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
|
||||
setShowSuggestions(true);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
}
|
||||
},
|
||||
[nbaBase],
|
||||
);
|
||||
|
||||
// Fetch odds for selected player to pre-fill lines
|
||||
const fetchPlayerOdds = useCallback(
|
||||
async (playerName: string, selectedSport: Sport) => {
|
||||
try {
|
||||
const sportKey = selectedSport.toLowerCase();
|
||||
const res = await fetch(`${apiBase}/api/odds/${sportKey}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const props: OddsLine[] = (data.props || []).filter(
|
||||
(p: OddsLine) => p.player?.toLowerCase() === playerName.toLowerCase(),
|
||||
);
|
||||
setPlayerOdds(props);
|
||||
} catch {
|
||||
setPlayerOdds([]);
|
||||
}
|
||||
},
|
||||
[apiBase],
|
||||
);
|
||||
|
||||
// When player is selected, fetch their odds
|
||||
const selectPlayer = (name: string) => {
|
||||
setSelectedPlayer(name);
|
||||
setPlayerQuery(name);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
setStatType('');
|
||||
setLine('');
|
||||
fetchPlayerOdds(name, sport);
|
||||
};
|
||||
|
||||
// When stat changes, pre-fill line from odds
|
||||
useEffect(() => {
|
||||
if (!selectedPlayer || !statType) return;
|
||||
const match = playerOdds.find((o) => o.stat_type === statType);
|
||||
if (match) {
|
||||
setLine(match.line);
|
||||
setDirection((match.direction as 'over' | 'under') || 'over');
|
||||
} else {
|
||||
setLine('');
|
||||
}
|
||||
}, [statType, selectedPlayer, playerOdds]);
|
||||
|
||||
// Available stats for this player based on odds data
|
||||
const availableStats = playerOdds.length > 0
|
||||
? stats.filter((s) => playerOdds.some((o) => o.stat_type === s))
|
||||
: stats;
|
||||
|
||||
const canScan = selectedPlayer && statType && line !== '';
|
||||
|
||||
const handleScan = () => {
|
||||
if (!canScan || scanning) return;
|
||||
onScan({
|
||||
player: selectedPlayer,
|
||||
stat_type: statType,
|
||||
line: Number(line),
|
||||
direction,
|
||||
sport,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5" data-testid="simplified-selector">
|
||||
{/* Sport Toggle */}
|
||||
<div className="flex gap-2" data-testid="sport-toggle">
|
||||
{(['NBA', 'MLB'] as Sport[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSport(s)}
|
||||
className={`flex-1 py-3 rounded-xl font-mono font-bold text-sm transition ${
|
||||
sport === s
|
||||
? 'bg-[var(--cyan)] text-black'
|
||||
: 'bg-[var(--card)] border border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--cyan)]'
|
||||
}`}
|
||||
data-testid={`sport-${s.toLowerCase()}`}
|
||||
aria-pressed={sport === s}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Player Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${sport} player...`}
|
||||
value={playerQuery}
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
searchPlayer(e.target.value);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--cyan)] text-sm"
|
||||
data-testid="player-search"
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden" data-testid="player-suggestions">
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onMouseDown={() => selectPlayer(name)}
|
||||
className="block w-full text-left px-4 py-2 text-sm hover:bg-[var(--border)] transition"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat Dropdown */}
|
||||
{selectedPlayer && (
|
||||
<select
|
||||
value={statType}
|
||||
onChange={(e) => setStatType(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
data-testid="stat-dropdown"
|
||||
>
|
||||
<option value="">Select stat...</option>
|
||||
{availableStats.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Line + Direction */}
|
||||
{selectedPlayer && statType && (
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value as 'over' | 'under')}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
data-testid="direction-select"
|
||||
>
|
||||
<option value="over">Over</option>
|
||||
<option value="under">Under</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="Line"
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value ? Number(e.target.value) : '')}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] text-sm"
|
||||
data-testid="line-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan Button */}
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={!canScan || scanning}
|
||||
className="w-full py-3 bg-[var(--cyan)] text-black rounded-xl font-medium hover:bg-[var(--cyan-hover)] transition disabled:opacity-40"
|
||||
data-testid="scan-button"
|
||||
>
|
||||
{scanning ? 'Reading...' : 'Read Prop'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type Size = number | string;
|
||||
|
||||
const pulseStyle = (extra: CSSProperties = {}): CSSProperties => ({
|
||||
background: 'var(--bg-2)',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
...extra,
|
||||
});
|
||||
|
||||
export function SkeletonBox({
|
||||
width,
|
||||
height,
|
||||
borderRadius = 8,
|
||||
style,
|
||||
}: {
|
||||
width?: Size;
|
||||
height?: Size;
|
||||
borderRadius?: number;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={pulseStyle({
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
...style,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonLine({ width = '80%', height = 14 }: { width?: Size; height?: Size }) {
|
||||
return <SkeletonBox width={width} height={height} borderRadius={4} style={{ marginBottom: 8 }} />;
|
||||
}
|
||||
|
||||
export function SkeletonGradeCard() {
|
||||
return (
|
||||
<div className="surface" style={{ padding: 16, display: 'grid', gap: 12 }} aria-label="Loading grade card" role="status">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<SkeletonBox width={60} height={20} borderRadius={999} />
|
||||
<SkeletonBox width={36} height={36} borderRadius={999} />
|
||||
</div>
|
||||
<SkeletonBox width="70%" height={22} borderRadius={6} />
|
||||
<SkeletonBox width="45%" height={14} borderRadius={4} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||
<SkeletonBox width={72} height={72} borderRadius={12} />
|
||||
</div>
|
||||
<SkeletonLine width="90%" />
|
||||
<SkeletonLine width="80%" />
|
||||
<SkeletonLine width="75%" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonGameCard() {
|
||||
return (
|
||||
<div
|
||||
className="surface"
|
||||
style={{ padding: 16, display: 'flex', gap: 12, alignItems: 'stretch' }}
|
||||
aria-label="Loading game card"
|
||||
role="status"
|
||||
>
|
||||
<SkeletonBox width={3} height={56} borderRadius={2} />
|
||||
<div style={{ flex: 1, display: 'grid', gap: 8 }}>
|
||||
<SkeletonBox width="55%" height={18} borderRadius={4} />
|
||||
<SkeletonBox width="35%" height={14} borderRadius={4} />
|
||||
</div>
|
||||
<SkeletonBox width={60} height={20} borderRadius={999} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRow({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 8 }} aria-label="Loading" role="status">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonGameCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonGradeRail({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '4px 4px 12px' }} aria-label="Loading grade rail" role="status">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} style={{ minWidth: 220, flex: '0 0 auto' }}>
|
||||
<SkeletonGradeCard />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const PREF_KEY = 'vyndr.sportsbook_modal.suppress';
|
||||
|
||||
type Props = {
|
||||
book: string;
|
||||
url: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SportsbookModal({ book, url, open, onClose }: Props) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const continueOut = () => {
|
||||
if (dontShowAgain) {
|
||||
try { localStorage.setItem(PREF_KEY, '1'); } catch { /* private mode */ }
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sportsbook-modal-title"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: 'rgba(6, 6, 11, 0.72)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="surface diagonal-cut"
|
||||
style={{ maxWidth: 400, width: '100%', padding: 24 }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEAVING VYNDR</p>
|
||||
<p id="sportsbook-modal-title" style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>
|
||||
You're being redirected to {book}.
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginTop: 8 }}>
|
||||
VYNDR doesn't place bets, handle money, or guarantee outcomes.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
|
||||
<button type="button" className="btn-primary" style={{ flex: 1 }} onClick={continueOut}>
|
||||
Continue to {book} →
|
||||
</button>
|
||||
<button type="button" className="btn-ghost" style={{ flex: 1 }} onClick={onClose}>
|
||||
Stay on VYNDR
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontShowAgain}
|
||||
onChange={(e) => setDontShowAgain(e.target.checked)}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-2)', fontSize: 12 }}>Don't show this again</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSkipSportsbookModal(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(PREF_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openSportsbookSafely(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type WordmarkProps = {
|
||||
size?: number;
|
||||
cursor?: boolean;
|
||||
animated?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* VYNDR wordmark. IBM Plex Mono 800, RGB-split letters, glowing R, blinking cursor.
|
||||
* The R is the brand — always green (#00D4A0), always intercepted-looking.
|
||||
* `aria-label` exposes the readable brand to screen readers; per-letter spans are aria-hidden.
|
||||
*/
|
||||
export default function Wordmark({
|
||||
size = 22,
|
||||
cursor = true,
|
||||
animated = true,
|
||||
ariaLabel = 'VYNDR',
|
||||
}: WordmarkProps) {
|
||||
const style: CSSProperties & { '--wm-size'?: string } = {
|
||||
fontSize: size,
|
||||
'--wm-size': `${size}px`,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`wordmark${animated ? ' wm-anim' : ''}`}
|
||||
style={style}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className="vynd" aria-hidden>
|
||||
<span className="wm-letter" data-text="V">V</span>
|
||||
<span className="wm-letter" data-text="Y">Y</span>
|
||||
<span className="wm-letter" data-text="N">N</span>
|
||||
<span className="wm-letter" data-text="D">D</span>
|
||||
</span>
|
||||
<span className="r wm-letter" data-text="R" aria-hidden>R</span>
|
||||
{cursor ? <span className="wm-cursor" aria-hidden /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Sport activation config — frontend mirror.
|
||||
* Keep values aligned with `src/config/sports.js` in the Node backend.
|
||||
*/
|
||||
export type SportKey =
|
||||
| 'nba' | 'wnba' | 'mlb'
|
||||
| 'nfl' | 'nhl' | 'tennis' | 'mma' | 'boxing' | 'golf';
|
||||
|
||||
export interface SportConfig {
|
||||
key: SportKey;
|
||||
label: string;
|
||||
color: string;
|
||||
active: boolean;
|
||||
collectData: boolean;
|
||||
comingSoon?: string;
|
||||
}
|
||||
|
||||
export const SPORTS: Record<SportKey, SportConfig> = {
|
||||
nba: { key: 'nba', label: 'NBA', color: '#E94B3C', active: true, collectData: true },
|
||||
wnba: { key: 'wnba', label: 'WNBA', color: '#F7944A', active: true, collectData: true },
|
||||
mlb: { key: 'mlb', label: 'MLB', color: '#1E90FF', active: true, collectData: true },
|
||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
};
|
||||
|
||||
export const ALL_SPORTS = Object.values(SPORTS);
|
||||
export const ACTIVE_SPORTS = ALL_SPORTS.filter((s) => s.active);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
let initialized = false;
|
||||
let posthogReady = false;
|
||||
|
||||
export function initAnalytics(): void {
|
||||
if (initialized || typeof window === 'undefined') return;
|
||||
initialized = true;
|
||||
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
if (!key) return;
|
||||
posthog.init(key, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
autocapture: false,
|
||||
loaded: (ph) => {
|
||||
posthogReady = true;
|
||||
if (process.env.NODE_ENV === 'development') ph.debug();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function safeCapture(event: string, properties: Record<string, unknown> = {}) {
|
||||
if (!posthogReady) return;
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
} catch {
|
||||
// analytics failures should never break the app
|
||||
}
|
||||
}
|
||||
|
||||
export function identifyUser(userId: string, properties: Record<string, unknown> = {}) {
|
||||
if (!posthogReady) return;
|
||||
try {
|
||||
posthog.identify(userId, properties);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export function resetIdentity() {
|
||||
if (!posthogReady) return;
|
||||
try {
|
||||
posthog.reset();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(path: string) {
|
||||
safeCapture('page_viewed', { path });
|
||||
}
|
||||
|
||||
export function trackScanCompleted(data: {
|
||||
sport: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
grade: string;
|
||||
tier: string;
|
||||
}) {
|
||||
safeCapture('scan_completed', data);
|
||||
}
|
||||
|
||||
export function trackParlayBuilt(data: { legs: number; sports: string[]; grade: string }) {
|
||||
safeCapture('parlay_built', data);
|
||||
}
|
||||
|
||||
export function trackUpgradeClicked(data: {
|
||||
current_tier: string;
|
||||
target_tier: string;
|
||||
trigger_location: string;
|
||||
}) {
|
||||
safeCapture('upgrade_clicked', data);
|
||||
}
|
||||
|
||||
export function trackShareCardGenerated(data: { sport: string; grade: string }) {
|
||||
safeCapture('share_card_generated', data);
|
||||
}
|
||||
|
||||
export function trackScanLimitHit(data: { current_scan_count: number; tier: string }) {
|
||||
safeCapture('scan_limit_hit', data);
|
||||
}
|
||||
|
||||
export function trackSignup(data: { method: string }) {
|
||||
safeCapture('signup', data);
|
||||
}
|
||||
|
||||
export function trackLogin(data: { method: string }) {
|
||||
safeCapture('login', data);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getServerSupabase } from './supabase';
|
||||
|
||||
export interface AuthedUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
tier: 'free' | 'analyst' | 'desk';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a bearer token from the Authorization header against Supabase.
|
||||
* Returns null when missing/invalid — callers decide whether to 401.
|
||||
*/
|
||||
export async function getUserFromRequest(req: NextRequest): Promise<AuthedUser | null> {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth || !auth.toLowerCase().startsWith('bearer ')) return null;
|
||||
|
||||
const sb = getServerSupabase(auth);
|
||||
if (!sb) return null;
|
||||
|
||||
const { data, error } = await sb.auth.getUser();
|
||||
if (error || !data.user) return null;
|
||||
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('tier')
|
||||
.eq('id', data.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
return {
|
||||
id: data.user.id,
|
||||
email: data.user.email ?? null,
|
||||
tier: ((profile?.tier as AuthedUser['tier']) ?? 'free'),
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonError(status: number, message: string) {
|
||||
return Response.json({ error: message }, { status });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Tracks Reads completed in localStorage. Call markReadComplete() once a user
|
||||
// has actually viewed a grade card or scan result — *not* on page load.
|
||||
// InstallPrompt and PushPrompt use this counter to gate when they appear.
|
||||
|
||||
const READS_KEY = 'vyndr_reads_completed';
|
||||
|
||||
export function markReadComplete(): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
const next = readsCompleted() + 1;
|
||||
window.localStorage.setItem(READS_KEY, String(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function readsCompleted(): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
const raw = window.localStorage.getItem(READS_KEY);
|
||||
return raw ? parseInt(raw, 10) || 0 : 0;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
let browserClient: SupabaseClient | null = null;
|
||||
|
||||
export function getBrowserSupabase(): SupabaseClient | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (!url || !anonKey) return null;
|
||||
if (browserClient) return browserClient;
|
||||
browserClient = createClient(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
return browserClient;
|
||||
}
|
||||
|
||||
export function getServerSupabase(authHeader?: string | null): SupabaseClient | null {
|
||||
if (!url || !anonKey) return null;
|
||||
return createClient(url, anonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: authHeader ? { headers: { Authorization: authHeader } } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function getServiceRoleSupabase(): SupabaseClient | null {
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!url || !serviceKey) return null;
|
||||
return createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Per-tier rate limiter for /api/scan and other write endpoints.
|
||||
*
|
||||
* Bucket model:
|
||||
* - free: 5 scans/minute, daily cap of 5 (the daily cap is enforced
|
||||
* elsewhere in the route handler)
|
||||
* - analyst: 30 scans/minute, unlimited daily
|
||||
* - desk: 60 scans/minute, unlimited daily
|
||||
*
|
||||
* Storage:
|
||||
* - In-memory ring buffer per key.
|
||||
* - Process-local — good for single-instance Vercel deployments. For
|
||||
* multi-instance later, swap to upstash/redis without changing the
|
||||
* external interface.
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
const LIMITS: Record<Tier, number> = {
|
||||
free: 5,
|
||||
analyst: 30,
|
||||
desk: 60,
|
||||
};
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
interface Bucket {
|
||||
hits: number[]; // ms timestamps
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export function rateLimitCheck(key: string, tier: Tier): { ok: true } | { ok: false; retryAfter: number } {
|
||||
const limit = LIMITS[tier] ?? LIMITS.free;
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { hits: [] };
|
||||
|
||||
// Drop hits older than the window
|
||||
while (bucket.hits.length && now - bucket.hits[0] > WINDOW_MS) {
|
||||
bucket.hits.shift();
|
||||
}
|
||||
|
||||
if (bucket.hits.length >= limit) {
|
||||
const oldest = bucket.hits[0];
|
||||
const retryAfter = Math.max(1, Math.ceil((WINDOW_MS - (now - oldest)) / 1000));
|
||||
buckets.set(key, bucket);
|
||||
return { ok: false, retryAfter };
|
||||
}
|
||||
|
||||
bucket.hits.push(now);
|
||||
buckets.set(key, bucket);
|
||||
|
||||
// Opportunistic GC — keep the map small in long-running processes
|
||||
if (buckets.size > 5000 && Math.random() < 0.01) {
|
||||
pruneStale();
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function rateLimitResponse(retryAfter: number) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Slow down — you're reading faster than the model can think. Try again in a minute.",
|
||||
retryAfter,
|
||||
},
|
||||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable key from the incoming request. Prefers the auth bearer
|
||||
* (per-user) and falls back to the forwarded IP for anonymous traffic.
|
||||
*/
|
||||
export function rateLimitKey(req: NextRequest): string {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth) return `user:${auth.slice(-32)}`;
|
||||
const fwd = req.headers.get('x-forwarded-for') || '';
|
||||
const ip = fwd.split(',')[0].trim() || 'anon';
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
function pruneStale() {
|
||||
const cutoff = Date.now() - WINDOW_MS * 2;
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (!bucket.hits.length || bucket.hits[bucket.hits.length - 1] < cutoff) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Transactional email via Resend.
|
||||
*
|
||||
* Three flows for launch:
|
||||
* - sendWelcomeEmail() — on signup
|
||||
* - sendPaymentReceipt() — on successful NexaPay webhook
|
||||
* - sendRenewalReminder() — daily cron when subscription_end < 3 days out
|
||||
*
|
||||
* All functions return { ok: boolean, id?: string, error?: string } and
|
||||
* never throw — email is best-effort and must not break the auth or
|
||||
* payment flow if Resend is unreachable.
|
||||
*/
|
||||
|
||||
const RESEND_API = 'https://api.resend.com/emails';
|
||||
const FROM_DEFAULT = 'VYNDR <grades@vyndr.app>';
|
||||
|
||||
interface SendResult {
|
||||
ok: boolean;
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function send(payload: { to: string; subject: string; html: string; text: string }): Promise<SendResult> {
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('[email] RESEND_API_KEY not set, skipping send to', payload.to);
|
||||
}
|
||||
return { ok: false, error: 'RESEND_API_KEY missing' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(RESEND_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: process.env.RESEND_FROM_EMAIL || FROM_DEFAULT,
|
||||
to: [payload.to],
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { ok: false, error: `${res.status} ${body.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: true, id: (data as { id?: string }).id };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
const TEMPLATE_FOOTER = `\n\n— VYNDR\nBuilt in Detroit.\n\nNot a sportsbook. Gamble responsibly. 1-800-522-4700.\n`;
|
||||
|
||||
const TEMPLATE_HTML_WRAP = (body: string) => `
|
||||
<!doctype html>
|
||||
<html><body style="margin:0;padding:32px 16px;background:#0A0A0F;color:#F0F0F5;font-family:'Instrument Sans',-apple-system,system-ui,sans-serif;line-height:1.6">
|
||||
<div style="max-width:560px;margin:0 auto;background:#12121A;border:1px solid #2A2A3A;border-radius:16px;padding:32px">
|
||||
<h1 style="font-size:22px;font-weight:800;letter-spacing:0.10em;margin:0 0 24px;color:#E8E8F0;font-family:'IBM Plex Mono','JetBrains Mono',monospace">
|
||||
VYND<span style="color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">R</span>
|
||||
</h1>
|
||||
${body}
|
||||
<hr style="border:none;border-top:1px solid #2A2A3A;margin:32px 0 16px" />
|
||||
<p style="font-size:11px;color:#5A5A6A;margin:0;font-family:'JetBrains Mono',monospace">
|
||||
Built in Detroit. Not a sportsbook. Gamble responsibly. 1-800-522-4700.
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
export async function sendWelcomeEmail(email: string): Promise<SendResult> {
|
||||
const subject = "You're in. Let's grade some props.";
|
||||
const body = `
|
||||
<p style="font-size:16px">Welcome to VYNDR.</p>
|
||||
<p>You have <strong>5 free reads every month</strong>. Pick a game, read a prop, and see what the model thinks.</p>
|
||||
<p>The books have every advantage. Now you have one too.</p>
|
||||
<p style="margin-top:24px"><a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Open the slate →
|
||||
</a></p>
|
||||
`;
|
||||
const text =
|
||||
`Welcome to VYNDR.
|
||||
|
||||
You have 5 free reads every month. Pick a game, read a prop, and see what the model thinks.
|
||||
|
||||
The books have every advantage. Now you have one too.
|
||||
|
||||
Open the slate: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
export async function sendPaymentReceipt(
|
||||
email: string,
|
||||
opts: { tier: 'analyst' | 'desk'; amount: string; renewsAt: string },
|
||||
): Promise<SendResult> {
|
||||
const tierLabel = opts.tier === 'desk' ? 'Desk' : 'Analyst';
|
||||
const subject = `Receipt — VYNDR ${tierLabel} Access`;
|
||||
const body = `
|
||||
<p style="font-size:16px">Payment received. You’re in.</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0">
|
||||
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Tier</td>
|
||||
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${tierLabel}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Amount</td>
|
||||
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${opts.amount}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Renews</td>
|
||||
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${opts.renewsAt}</td></tr>
|
||||
</table>
|
||||
<p>Full intelligence unlocked. Go read something.</p>
|
||||
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/scan"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Start reading →
|
||||
</a></p>
|
||||
`;
|
||||
const text =
|
||||
`Payment received. You're in.
|
||||
|
||||
Tier: ${tierLabel}
|
||||
Amount: ${opts.amount}
|
||||
Renews: ${opts.renewsAt}
|
||||
|
||||
Full intelligence unlocked. Go read something.
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
// Tiny HTML escape so any caller-supplied value can't inject markup.
|
||||
const esc = (s: string | number) =>
|
||||
String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, resetLink: string): Promise<SendResult> {
|
||||
const subject = 'Reset your VYNDR password';
|
||||
const safeLink = esc(resetLink);
|
||||
const body = `
|
||||
<p style="font-size:16px">Reset your password.</p>
|
||||
<p>Click the link below to set a new password. This link expires in 1 hour and can only be used once.</p>
|
||||
<p style="margin-top:24px">
|
||||
<a href="${safeLink}"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Reset Password →
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin-top:16px;color:#7A7A8E;font-size:13px">
|
||||
If you didn’t request this, ignore this email. Your password won’t change.
|
||||
</p>
|
||||
`;
|
||||
const text =
|
||||
`Reset your VYNDR password.
|
||||
|
||||
Click here: ${resetLink}
|
||||
|
||||
This link expires in 1 hour and can only be used once. If you didn't request this, ignore this email.
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
export async function sendQuotaReminderEmail(email: string): Promise<SendResult> {
|
||||
const subject = 'You used all 5 reads this month. Good taste.';
|
||||
const checkoutUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/api/checkout?tier=analyst`;
|
||||
const body = `
|
||||
<p style="font-size:16px">You used all 5 reads this month.</p>
|
||||
<p>Next month you get 5 more — or unlock unlimited right now.</p>
|
||||
<p style="margin-top:16px">
|
||||
<span style="font-family:'IBM Plex Mono','JetBrains Mono',monospace;font-size:28px;font-weight:800;color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">$14.99</span>
|
||||
<span style="color:#7A7A8E;font-size:14px">/mo · Locked for life</span>
|
||||
</p>
|
||||
<p style="color:#FFB347;font-size:13px;font-weight:600">This rate disappears June 15.</p>
|
||||
<p style="margin-top:20px">
|
||||
<a href="${esc(checkoutUrl)}"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Unlock Unlimited Reads →
|
||||
</a>
|
||||
</p>
|
||||
`;
|
||||
const text =
|
||||
`You used all 5 reads this month.
|
||||
|
||||
Next month you get 5 more — or unlock unlimited for $14.99/mo.
|
||||
|
||||
This rate disappears June 15.
|
||||
|
||||
${checkoutUrl}
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
export async function sendCancellationEmail(
|
||||
email: string,
|
||||
opts: { accessUntil: string; iqScore?: number; record?: string },
|
||||
): Promise<SendResult> {
|
||||
const subject = "We're sorry to see you go.";
|
||||
const pricingUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/pricing`;
|
||||
const statsLine = opts.iqScore != null
|
||||
? `<p style="color:#7A7A8E;font-size:14px">VYNDR IQ: ${esc(opts.iqScore)}. Record: ${esc(opts.record ?? 'N/A')}. You were on a good run.</p>`
|
||||
: '';
|
||||
const body = `
|
||||
<p style="font-size:16px">Your subscription has been cancelled.</p>
|
||||
<p>Your access continues until <strong>${esc(opts.accessUntil)}</strong>. Your Ledger and grade history are still here if you come back.</p>
|
||||
${statsLine}
|
||||
<p style="margin-top:20px">
|
||||
<a href="${esc(pricingUrl)}"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Come Back Anytime →
|
||||
</a>
|
||||
</p>
|
||||
`;
|
||||
const text =
|
||||
`Your VYNDR subscription has been cancelled.
|
||||
|
||||
Access continues until ${opts.accessUntil}.
|
||||
|
||||
${opts.iqScore != null ? `VYNDR IQ: ${opts.iqScore}. Record: ${opts.record ?? 'N/A'}.\n` : ''}Come back anytime: ${pricingUrl}
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
export async function sendRenewalReminder(
|
||||
email: string,
|
||||
opts: { daysLeft: number; renewalLink: string; tier: string },
|
||||
): Promise<SendResult> {
|
||||
const subject = `Your VYNDR access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}`;
|
||||
const body = `
|
||||
<p style="font-size:16px">Your <strong>${opts.tier}</strong> access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}.</p>
|
||||
<p>If you renew, you keep the same pricing and zero interruption to your reads. If you don’t, you’ll drop back to 5 reads/month with the analysis blurred.</p>
|
||||
<p style="margin-top:24px"><a href="${opts.renewalLink}"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Renew now →
|
||||
</a></p>
|
||||
`;
|
||||
const text =
|
||||
`Your ${opts.tier} access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}.
|
||||
|
||||
Renew now: ${opts.renewalLink}
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* NexaPay payment processor wrapper.
|
||||
*
|
||||
* NexaPay accepts cards (Visa/Mastercard/Apple Pay/Google Pay) on the customer
|
||||
* side and settles to VYNDR in stablecoin (USDC/USDT). The customer never
|
||||
* sees crypto.
|
||||
*
|
||||
* Required env vars (set on the deployment, never commit):
|
||||
* NEXAPAY_API_KEY — bearer token used for outbound API calls
|
||||
* NEXAPAY_WEBHOOK_SECRET — HMAC secret for verifying inbound webhooks
|
||||
* NEXAPAY_API_URL — defaults to https://api.nexapay.one/v1
|
||||
* NEXT_PUBLIC_SITE_URL — used to construct redirect + webhook URLs
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXAPAY_API_URL || 'https://api.nexapay.one/v1';
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
|
||||
export type NexaPayTier = 'analyst' | 'desk';
|
||||
|
||||
export interface CreatePaymentLinkParams {
|
||||
userId: string;
|
||||
tier: NexaPayTier;
|
||||
amount: number; // dollars, e.g. 14.99
|
||||
description: string;
|
||||
founderPricing?: boolean;
|
||||
}
|
||||
|
||||
export interface NexaPayPaymentLink {
|
||||
id: string;
|
||||
url: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface NexaPayWebhookEvent {
|
||||
id: string;
|
||||
type: 'payment.succeeded' | 'payment.failed' | 'payment.refunded' | 'subscription.canceled';
|
||||
created: number;
|
||||
data: {
|
||||
payment_id: string;
|
||||
customer_id?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
metadata: Record<string, string>;
|
||||
settled_amount?: number;
|
||||
settled_currency?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function requireApiKey(): string {
|
||||
const key = process.env.NEXAPAY_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error('NEXAPAY_API_KEY is not set');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function createPaymentLink(params: CreatePaymentLinkParams): Promise<NexaPayPaymentLink> {
|
||||
const apiKey = requireApiKey();
|
||||
|
||||
const body = {
|
||||
amount: Math.round(params.amount * 100),
|
||||
currency: 'USD',
|
||||
description: params.description,
|
||||
redirect_url: `${SITE_URL}/scan?upgraded=true`,
|
||||
cancel_url: `${SITE_URL}/?canceled=true#pricing`,
|
||||
webhook_url: `${SITE_URL}/api/webhook/nexapay`,
|
||||
customer_reference: params.userId,
|
||||
metadata: {
|
||||
userId: params.userId,
|
||||
tier: params.tier,
|
||||
type: 'subscription',
|
||||
founderPricing: String(params.founderPricing ?? false),
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_URL}/payment-links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error(`NexaPay create payment link failed (${res.status}): ${errBody}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as NexaPayPaymentLink;
|
||||
}
|
||||
|
||||
export async function getTransaction(paymentId: string) {
|
||||
const apiKey = requireApiKey();
|
||||
const res = await fetch(`${API_URL}/payments/${paymentId}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`NexaPay get transaction failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NexaPay webhook signature.
|
||||
* NexaPay sends `x-nexapay-signature: t=<unix>, v1=<hex>` where v1 is
|
||||
* HMAC-SHA256(secret, `${t}.${rawBody}`).
|
||||
*/
|
||||
export function verifyWebhookSignature(rawBody: string, signatureHeader: string | null): boolean {
|
||||
const secret = process.env.NEXAPAY_WEBHOOK_SECRET;
|
||||
if (!secret || !signatureHeader) return false;
|
||||
|
||||
const parts = signatureHeader.split(',').reduce<Record<string, string>>((acc, part) => {
|
||||
const [k, v] = part.trim().split('=');
|
||||
if (k && v) acc[k] = v;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const timestamp = parts['t'];
|
||||
const expected = parts['v1'];
|
||||
if (!timestamp || !expected) return false;
|
||||
|
||||
// 5-minute replay window
|
||||
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
|
||||
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
|
||||
|
||||
const computed = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${timestamp}.${rawBody}`)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const TIER_PRICING: Record<NexaPayTier, { regular: number; founder: number; label: string }> = {
|
||||
analyst: { regular: 24.99, founder: 14.99, label: 'VYNDR Analyst — Monthly' },
|
||||
desk: { regular: 49.99, founder: 44.99, label: 'VYNDR Desk — Monthly' },
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Supabase-backed cache wrapper around upstream Odds API and backend
|
||||
* grading-engine calls. Keeps user-facing requests off the rate-limited
|
||||
* upstream API (500 req/mo on free tier) by serving from a 5-minute TTL
|
||||
* cache row in `odds_cache`.
|
||||
*
|
||||
* Cache key pattern: `{sport}:{data_type}:{date?}`
|
||||
* e.g. `nba:games:2026-05-18`, `mlb:props:2026-05-18`, `wnba:games:today`
|
||||
*
|
||||
* Failure mode: if Supabase is unreachable, we still call the loader so
|
||||
* a fresh response is returned. If both Supabase AND the loader fail,
|
||||
* the caller gets the stale cache row (if any) or the loader's thrown
|
||||
* error.
|
||||
*/
|
||||
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
payload: T;
|
||||
fetched_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_SECONDS = 300; // 5 min
|
||||
|
||||
export interface CachedFetchOptions<T> {
|
||||
/**
|
||||
* Unique cache key. Reuse it across calls that want the same data.
|
||||
*/
|
||||
key: string;
|
||||
sport: string;
|
||||
dataType: string;
|
||||
/** How long the row stays fresh. Defaults to 300s. */
|
||||
ttlSeconds?: number;
|
||||
/** Loader called on a miss. Must return a value or throw. */
|
||||
loader: () => Promise<T>;
|
||||
/**
|
||||
* If true, returns the cached row even after it has expired when
|
||||
* the loader throws. Defaults to true.
|
||||
*/
|
||||
fallbackToStale?: boolean;
|
||||
}
|
||||
|
||||
export async function cachedFetch<T>(opts: CachedFetchOptions<T>): Promise<T> {
|
||||
const sb = getServiceRoleSupabase();
|
||||
const now = new Date();
|
||||
const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
||||
|
||||
// 1. Try the cache.
|
||||
let cached: CacheEntry<T> | null = null;
|
||||
if (sb) {
|
||||
try {
|
||||
const { data } = await sb
|
||||
.from('odds_cache')
|
||||
.select('payload, fetched_at, expires_at')
|
||||
.eq('cache_key', opts.key)
|
||||
.maybeSingle();
|
||||
if (data) cached = data as CacheEntry<T>;
|
||||
} catch {
|
||||
/* fall through to loader */
|
||||
}
|
||||
}
|
||||
|
||||
if (cached && new Date(cached.expires_at) > now) {
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
// 2. Refresh via the loader.
|
||||
try {
|
||||
const fresh = await opts.loader();
|
||||
if (sb) {
|
||||
const expires = new Date(now.getTime() + ttl * 1000).toISOString();
|
||||
// upsert is racy but the conflict is harmless — last writer wins.
|
||||
await sb
|
||||
.from('odds_cache')
|
||||
.upsert(
|
||||
{
|
||||
cache_key: opts.key,
|
||||
sport: opts.sport,
|
||||
data_type: opts.dataType,
|
||||
payload: fresh as unknown as object,
|
||||
fetched_at: now.toISOString(),
|
||||
expires_at: expires,
|
||||
},
|
||||
{ onConflict: 'cache_key' },
|
||||
);
|
||||
}
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
if (opts.fallbackToStale !== false && cached) return cached.payload;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a `fetch` against the BACKEND_URL with the cache. Useful for
|
||||
* routes that pass-through to the Express grading engine but want to
|
||||
* absorb its load spikes.
|
||||
*/
|
||||
export async function cachedBackendJson<T>(
|
||||
key: string,
|
||||
sport: string,
|
||||
dataType: string,
|
||||
backendPath: string,
|
||||
ttlSeconds = DEFAULT_TTL_SECONDS,
|
||||
): Promise<T> {
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
return cachedFetch<T>({
|
||||
key,
|
||||
sport,
|
||||
dataType,
|
||||
ttlSeconds,
|
||||
loader: async () => {
|
||||
const res = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
// Force fresh from backend so we control the TTL ourselves.
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`backend ${backendPath} returned ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for daily-keyed caches.
|
||||
*/
|
||||
export function todayKey(sport: string, dataType: string): string {
|
||||
const d = new Date().toISOString().slice(0, 10);
|
||||
return `${sport.toLowerCase()}:${dataType}:${d}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/// <reference lib="webworker" />
|
||||
/// <reference types="@serwist/next/typings" />
|
||||
|
||||
import { defaultCache } from '@serwist/next/worker';
|
||||
import { Serwist } from 'serwist';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__SW_MANIFEST: (string | { url: string; revision: string | null })[];
|
||||
};
|
||||
|
||||
const serwist = new Serwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: defaultCache,
|
||||
});
|
||||
|
||||
serwist.addEventListeners();
|
||||
|
||||
// Web Push handler — fires when the push service delivers a notification.
|
||||
// Pushes are emitted server-side by src/services/distribution/webPush.js.
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
let payload: { title?: string; body?: string; icon?: string; url?: string };
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'VYNDR', body: event.data.text() };
|
||||
}
|
||||
const { title = 'VYNDR', body = '', icon = '/icons/icon-192.png', url = '/' } = payload;
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data as { url?: string } | undefined)?.url ?? '/';
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
const existing = clients.find((c) => c.url.endsWith(url));
|
||||
if (existing) return existing.focus();
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user