Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+73
View File
@@ -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.');
}
}
+23
View File
@@ -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: [] });
}
}
+23
View File
@@ -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 });
}
}
+44
View File
@@ -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: [] });
}
}
+20
View File
@@ -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: [] });
}
}
+35
View File
@@ -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: [] });
}
}
+50
View File
@@ -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 });
}
+56
View File
@@ -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 212 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.');
}
}
+53
View File
@@ -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: [] });
}
}
+43
View File
@@ -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 });
}
+27
View File
@@ -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: [] });
}
}
+157
View File
@@ -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 });
}
}
+46
View File
@@ -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' };
}
+55
View File
@@ -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 ?? [] });
}
+37
View File
@@ -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',
});
}
+36
View File
@@ -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 });
}
+100
View File
@@ -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 });
}
+62
View File
@@ -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>
);
}
+2 -2
View File
@@ -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' },
}),
}}
/>
+3 -3
View File
@@ -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>
+499
View File
@@ -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&apos;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&apos;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&apos;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,
};
+106
View File
@@ -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&apos;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>
);
}
+348
View File
@@ -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&apos;t on tonight&apos;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 23 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
View File
@@ -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;
}
}
+160
View File
@@ -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&apos;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&apos;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
View File
@@ -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>
);
+248
View File
@@ -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
View File
@@ -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&apos;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)',
};
+121
View File
@@ -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&apos;ll add this email to that list. No spam. Cancel anytime.
</p>
</section>
</section>
);
}
+50
View File
@@ -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&apos;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
View File
@@ -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 />
</>
);
+134
View File
@@ -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>
);
}
+196
View File
@@ -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&apos;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)';
}
+73
View File
@@ -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&apos;s life better when it stops being fun. If you need to walk away from sports betting entirely, that&apos;s the right call. Cancel your subscription, delete the app, and reach out to one of the resources above.
</p>
</section>
</section>
);
}
+566 -249
View File
@@ -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&apos;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 23 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&apos;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;
}
}
+238
View File
@@ -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&apos;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&apos;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
View File
@@ -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&apos;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)',
};
+128
View File
@@ -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
View File
@@ -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>
);
}
+251
View File
@@ -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&apos;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>
);
}
+116
View File
@@ -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>
);
}
+99
View File
@@ -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&apos;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>
);
}
+171
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+79
View File
@@ -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&apos;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>
);
}
+46
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+96 -24
View File
@@ -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
View File
@@ -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&apos;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>
);
}
+527 -14
View File
@@ -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
View File
@@ -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&apos;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>
);
}
+64 -19
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+148
View File
@@ -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&apos;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>
);
}
+147
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+280
View File
@@ -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&apos;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>
);
}
+231
View File
@@ -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&apos;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>
);
}
+233
View File
@@ -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 &quot;{query}&quot;.
</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>
);
}
+19
View File
@@ -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
View File
@@ -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>
);
}
+134
View File
@@ -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&apos;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>
);
}
+183
View File
@@ -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);
}
+241
View File
@@ -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>
);
}
+96
View File
@@ -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>
);
}
+107
View File
@@ -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&apos;re being redirected to {book}.
</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, marginTop: 8 }}>
VYNDR doesn&apos;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&apos;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');
}
+42
View File
@@ -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>
);
}
+31
View File
@@ -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);
+224
View File
@@ -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;
}
+57
View File
@@ -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;
}
+123
View File
@@ -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;
}
+93
View File
@@ -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);
}
+39
View File
@@ -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 });
}
+18
View File
@@ -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;
}
+36
View File
@@ -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 },
});
}
+92
View File
@@ -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);
}
}
}
+248
View File
@@ -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&rsquo;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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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&rsquo;t request this, ignore this email. Your password won&rsquo;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&rsquo;t, you&rsquo;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 });
}
+144
View File
@@ -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' },
};
+131
View File
@@ -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}`;
}
+52
View File
@@ -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);
})
);
});