Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { createPaymentLink, TIER_PRICING, type NexaPayTier } from '@/services/nexapay';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const VALID_TIERS = new Set<NexaPayTier>(['analyst', 'desk']);
|
||||
|
||||
async function resolveTier(req: NextRequest): Promise<NexaPayTier | null> {
|
||||
const url = new URL(req.url);
|
||||
const queryTier = url.searchParams.get('tier');
|
||||
if (queryTier && VALID_TIERS.has(queryTier as NexaPayTier)) return queryTier as NexaPayTier;
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = (await req.json().catch(() => ({}))) as { tier?: string };
|
||||
if (body.tier && VALID_TIERS.has(body.tier as NexaPayTier)) return body.tier as NexaPayTier;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(req);
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to upgrade.');
|
||||
|
||||
const tier = await resolveTier(req);
|
||||
if (!tier) return jsonError(400, 'Pick a valid tier (analyst or desk).');
|
||||
|
||||
// Founder pricing eligibility — first 100 paid users overall
|
||||
let founderEligible = false;
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
const { count } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('founder_pricing', true);
|
||||
founderEligible = (count ?? 0) < 100;
|
||||
}
|
||||
|
||||
const pricing = TIER_PRICING[tier];
|
||||
const amount = founderEligible ? pricing.founder : pricing.regular;
|
||||
|
||||
try {
|
||||
const link = await createPaymentLink({
|
||||
userId: user.id,
|
||||
tier,
|
||||
amount,
|
||||
description: `${pricing.label}${founderEligible ? ' (Founder)' : ''}`,
|
||||
founderPricing: founderEligible,
|
||||
});
|
||||
|
||||
// For GET (used by Pricing CTA links), redirect directly.
|
||||
if (req.method === 'GET') {
|
||||
return NextResponse.redirect(link.url, { status: 303 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: link.url, expires_at: link.expires_at, founder_pricing: founderEligible });
|
||||
} catch (err) {
|
||||
console.error('[checkout] NexaPay link failed', err);
|
||||
return jsonError(502, 'Payment processor is unreachable. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ props: [] });
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
`game:props:${id}`,
|
||||
'mixed',
|
||||
'game_props',
|
||||
`/api/games/${encodeURIComponent(id)}/props`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ error: 'Missing game id.' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const game = await cachedBackendJson<Record<string, unknown>>(
|
||||
`game:detail:${id}`,
|
||||
'mixed',
|
||||
'game_detail',
|
||||
`/api/games/${encodeURIComponent(id)}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(game);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Game not found.' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
ab_grade_count?: number;
|
||||
injury_note?: string;
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', games: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const games = await cachedBackendJson<Game[]>(
|
||||
todayKey(sport, 'games'),
|
||||
sport,
|
||||
'games',
|
||||
`/api/games/tonight?sport=${sport}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ games: Array.isArray(games) ? games : [] },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' } },
|
||||
);
|
||||
} catch {
|
||||
// The slate genuinely may be empty (off-day). Return empty list so the UI
|
||||
// shows the branded empty state instead of an error.
|
||||
return NextResponse.json({ games: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// The page itself shows the blurred preview for non-Desk users, so we
|
||||
// still return a small set of signals (so the timeline scaffolding
|
||||
// looks alive behind the blur). For Desk users, return full feed.
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const limit = user.tier === 'desk' ? 50 : 8;
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ signals: unknown[] }>(
|
||||
`intelligence:feed:${limit}`,
|
||||
'mixed',
|
||||
'intelligence_feed',
|
||||
`/api/intelligence/feed?limit=${limit}`,
|
||||
60,
|
||||
);
|
||||
return NextResponse.json({ signals: Array.isArray(data?.signals) ? data.signals : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ signals: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cachedBackendJson } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await cachedBackendJson<{ buckets: unknown[] }>(
|
||||
'ledger:accuracy:rolling',
|
||||
'mixed',
|
||||
'ledger_accuracy',
|
||||
'/api/ledger/accuracy',
|
||||
600,
|
||||
);
|
||||
return NextResponse.json({ buckets: Array.isArray(data?.buckets) ? data.buckets : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ buckets: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_TIERS = new Set(['A', 'B', 'C', 'D']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || '').toUpperCase();
|
||||
const tier = (req.nextUrl.searchParams.get('tier') || '').toUpperCase();
|
||||
const limit = Math.min(60, Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 30)));
|
||||
|
||||
if (sport && !VALID_SPORTS.has(sport)) return NextResponse.json({ entries: [] });
|
||||
if (tier && !VALID_TIERS.has(tier)) return NextResponse.json({ entries: [] });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (sport) params.set('sport', sport);
|
||||
if (tier) params.set('tier', tier);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ entries: unknown[] }>(
|
||||
todayKey(sport || 'all', `ledger:${tier || 'all'}:${limit}`),
|
||||
sport || 'mixed',
|
||||
'ledger',
|
||||
`/api/ledger?${params}`,
|
||||
300,
|
||||
);
|
||||
return NextResponse.json({ entries: Array.isArray(data?.entries) ? data.entries : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ entries: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Body {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player?: string;
|
||||
stat?: string;
|
||||
line?: number;
|
||||
direction?: 'over' | 'under';
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_DIRS = new Set(['over', 'under']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
!body.sport || !VALID_SPORTS.has(body.sport) ||
|
||||
!body.player || typeof body.player !== 'string' ||
|
||||
!body.stat ||
|
||||
typeof body.line !== 'number' || !Number.isFinite(body.line) ||
|
||||
!body.direction || !VALID_DIRS.has(body.direction)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Missing or invalid leg fields.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ ok: true, persisted: false });
|
||||
|
||||
// RPC increments scan or parlay counter atomically.
|
||||
await sb.rpc('increment_parlay_leg_frequency', {
|
||||
p_player: body.player,
|
||||
p_stat: body.stat,
|
||||
p_line: body.line,
|
||||
p_dir: body.direction,
|
||||
p_sport: body.sport,
|
||||
p_scan_delta: 0,
|
||||
p_parlay_delta: 1,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Leg {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
}
|
||||
|
||||
interface Body {
|
||||
legs: Leg[];
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to grade parlays.');
|
||||
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.legs) || body.legs.length < 2 || body.legs.length > 12) {
|
||||
return jsonError(400, 'Send 2–12 legs.');
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/scan/parlay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: body.legs }),
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { error?: string }).error || 'The engine hit a wall on this parlay.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return jsonError(502, 'The engine hit a wall on this parlay.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const NBA_SERVICE = process.env.NBA_SERVICE_URL || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
const q = (req.nextUrl.searchParams.get('q') || '').trim();
|
||||
const gameId = req.nextUrl.searchParams.get('game_id') || '';
|
||||
|
||||
if (q.length < 2) return NextResponse.json({ players: [] });
|
||||
|
||||
try {
|
||||
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
|
||||
const url =
|
||||
sport === 'MLB'
|
||||
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
|
||||
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
|
||||
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return NextResponse.json({ players: [] });
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
|
||||
? (data as { results: unknown[] }).results
|
||||
: Array.isArray((data as { players?: unknown[] }).players)
|
||||
? (data as { players: unknown[] }).players
|
||||
: [];
|
||||
|
||||
const players: Player[] = rawPlayers.slice(0, 12).map((p) => {
|
||||
const obj = (p && typeof p === 'object' ? p : {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(obj.id ?? obj.player_id ?? obj.full_name ?? Math.random()),
|
||||
full_name: String(obj.full_name ?? obj.name ?? ''),
|
||||
team: typeof obj.team === 'string' ? obj.team : undefined,
|
||||
position: typeof obj.position === 'string' ? obj.position : undefined,
|
||||
};
|
||||
}).filter((p) => p.full_name);
|
||||
|
||||
return NextResponse.json({ players });
|
||||
} catch {
|
||||
return NextResponse.json({ players: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface LiveProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
graded_at: string;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<LiveProp[] | { error: string }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/props/live`, {
|
||||
next: { revalidate: 60 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Backend unavailable — return empty list, the UI shows the fallback line.
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LiveProp[];
|
||||
if (!Array.isArray(data)) {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
return NextResponse.json(data.slice(0, 24), { headers: cacheHeaders() });
|
||||
} catch {
|
||||
return NextResponse.json([], { status: 200, headers: cacheHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
function cacheHeaders() {
|
||||
return { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60;
|
||||
|
||||
interface ParlayedProp {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
parlay_count: number;
|
||||
scan_count: number;
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ props: [] });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const { data } = await sb
|
||||
.from('parlay_leg_frequency')
|
||||
.select('player_name, stat, line, over_under, sport, parlay_count, scan_count')
|
||||
.eq('game_date', today)
|
||||
.order('parlay_count', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (!data) return NextResponse.json({ props: [] });
|
||||
|
||||
const props: ParlayedProp[] = data.map((row) => ({
|
||||
player: row.player_name,
|
||||
stat: row.stat,
|
||||
line: Number(row.line),
|
||||
direction: row.over_under as 'over' | 'under',
|
||||
sport: row.sport as 'NBA' | 'MLB' | 'WNBA',
|
||||
parlay_count: row.parlay_count,
|
||||
scan_count: row.scan_count,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ props });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cachedBackendJson, todayKey } from '@/services/odds-cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 120;
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sport = (req.nextUrl.searchParams.get('sport') || 'NBA').toUpperCase();
|
||||
if (!VALID_SPORTS.has(sport)) {
|
||||
return NextResponse.json({ error: 'Unknown sport.', props: [] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await cachedBackendJson<{ props: unknown[] }>(
|
||||
todayKey(sport, 'top_graded'),
|
||||
sport,
|
||||
'top_graded',
|
||||
`/api/props/top-graded?sport=${sport}`,
|
||||
120,
|
||||
);
|
||||
return NextResponse.json({ props: Array.isArray(data?.props) ? data.props : [] });
|
||||
} catch {
|
||||
return NextResponse.json({ props: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { rateLimitCheck, rateLimitKey, rateLimitResponse } from '@/middleware/rateLimit';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const FREE_LIMIT = 5; // reads per calendar month
|
||||
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01';
|
||||
const isSameMonth = (date: string | null | undefined) =>
|
||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
||||
|
||||
const VALID_SPORTS = new Set(['NBA', 'MLB', 'WNBA']);
|
||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||
const VALID_NBA_STATS = new Set(['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers']);
|
||||
const VALID_MLB_STATS = new Set([
|
||||
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
||||
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
|
||||
]);
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ScanBody {
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
book?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ScanBody;
|
||||
try {
|
||||
body = (await req.json()) as ScanBody;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON body.');
|
||||
}
|
||||
|
||||
if (!VALID_SPORTS.has(body.sport)) return jsonError(400, 'Unknown sport.');
|
||||
if (!VALID_DIRECTIONS.has(body.direction)) return jsonError(400, 'Direction must be over or under.');
|
||||
if (typeof body.player !== 'string' || body.player.length === 0 || body.player.length > 80) {
|
||||
return jsonError(400, 'Player name is required.');
|
||||
}
|
||||
if (typeof body.line !== 'number' || !Number.isFinite(body.line) || body.line < 0 || body.line > 500) {
|
||||
return jsonError(400, 'Line must be a number between 0 and 500.');
|
||||
}
|
||||
|
||||
const validStats = body.sport === 'MLB' ? VALID_MLB_STATS : VALID_NBA_STATS;
|
||||
if (!validStats.has(body.stat)) {
|
||||
return jsonError(400, `Stat "${body.stat}" not supported for ${body.sport}.`);
|
||||
}
|
||||
|
||||
const user = await getUserFromRequest(req);
|
||||
const sb = getServiceRoleSupabase();
|
||||
|
||||
// Per-minute rate limit (different limit per tier)
|
||||
const rl = rateLimitCheck(rateLimitKey(req), user?.tier ?? 'free');
|
||||
if (!rl.ok) return rateLimitResponse(rl.retryAfter);
|
||||
|
||||
// Throttle free tier (monthly cap)
|
||||
if (user && user.tier === 'free' && sb) {
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
|
||||
|
||||
if (usedThisMonth >= FREE_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "You've used your 5 free reads this month. Unlock unlimited reads and full intelligence — Founder Access, $14.99/mo.",
|
||||
scans_remaining: 0,
|
||||
upgrade: { tier: 'analyst', price: 14.99 },
|
||||
},
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to backend grading engine
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/analyze/prop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sport: body.sport,
|
||||
player: body.player,
|
||||
stat_type: body.stat,
|
||||
line: body.line,
|
||||
direction: body.direction,
|
||||
book: body.book ?? 'draftkings',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data?.error || 'The engine hit a wall. Try that read again.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
|
||||
let scansRemaining: number | null = null;
|
||||
|
||||
if (user && sb) {
|
||||
void sb.rpc('increment_parlay_leg_frequency', {
|
||||
p_player: body.player,
|
||||
p_stat: body.stat,
|
||||
p_line: body.line,
|
||||
p_dir: body.direction,
|
||||
p_sport: body.sport,
|
||||
p_scan_delta: 1,
|
||||
p_parlay_delta: 0,
|
||||
});
|
||||
|
||||
void sb.from('scan_history').insert({
|
||||
user_id: user.id,
|
||||
sport: body.sport,
|
||||
player_name: body.player,
|
||||
stat: body.stat,
|
||||
line: body.line,
|
||||
direction: body.direction,
|
||||
grade: data.grade,
|
||||
projection: data.projection,
|
||||
confidence: data.confidence,
|
||||
factors: data.factors ?? null,
|
||||
});
|
||||
|
||||
if (user.tier === 'free') {
|
||||
const thisMonth = monthKey();
|
||||
const { data: current } = await sb
|
||||
.from('user_profiles')
|
||||
.select('scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const next = (isSameMonth(current?.scan_reset_date) ? (current?.scan_count ?? 0) : 0) + 1;
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ scan_count: next, scan_reset_date: thisMonth })
|
||||
.eq('id', user.id);
|
||||
scansRemaining = Math.max(0, FREE_LIMIT - next);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...data, scans_remaining: scansRemaining, tier: user?.tier ?? 'free' });
|
||||
} catch (err) {
|
||||
console.error('[scan] backend call failed', err);
|
||||
return jsonError(502, 'The engine hit a wall. Try that read again.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<{ count: number }>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/parlays-graded`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ count: 0 });
|
||||
const data = (await res.json()) as { count?: number };
|
||||
return NextResponse.json({ count: Number(data.count || 0) });
|
||||
} catch {
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface PublicStats {
|
||||
parlays_graded: number;
|
||||
kill_conditions_caught: number;
|
||||
a_grade_accuracy: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
const FALLBACK: PublicStats = {
|
||||
parlays_graded: 0,
|
||||
kill_conditions_caught: 0,
|
||||
a_grade_accuracy: 0,
|
||||
last_updated: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(): Promise<NextResponse<PublicStats>> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/stats/public`, {
|
||||
next: { revalidate: 30 },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
const data = (await res.json()) as Partial<PublicStats>;
|
||||
return NextResponse.json(
|
||||
{
|
||||
parlays_graded: Number(data.parlays_graded || 0),
|
||||
kill_conditions_caught: Number(data.kill_conditions_caught || 0),
|
||||
a_grade_accuracy: Number(data.a_grade_accuracy || 0),
|
||||
last_updated: data.last_updated || new Date().toISOString(),
|
||||
},
|
||||
{ headers: cache() },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(FALLBACK, { headers: cache() });
|
||||
}
|
||||
}
|
||||
|
||||
function cache() {
|
||||
return { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=120' };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.select('id, email, tier, scan_count, scan_reset_date, subscription_status, subscription_end, founder_pricing, cancel_at_period_end')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return jsonError(500, error.message);
|
||||
}
|
||||
return NextResponse.json(data ?? { id: user.id, email: user.email, tier: 'free' });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
let body: { age_verified?: boolean; cancel_at_period_end?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON body.');
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (typeof body.age_verified === 'boolean') update.age_verified = body.age_verified;
|
||||
if (typeof body.cancel_at_period_end === 'boolean') update.cancel_at_period_end = body.cancel_at_period_end;
|
||||
|
||||
if (Object.keys(update).length === 0) return jsonError(400, 'Nothing to update.');
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('user_profiles')
|
||||
.update(update)
|
||||
.eq('id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return NextResponse.json({ scans: [] });
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('scan_history')
|
||||
.select('id, sport, player_name, stat, line, direction, grade, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) return jsonError(500, error.message);
|
||||
return NextResponse.json({ scans: data ?? [] });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const FREE_LIMIT = 5; // reads per calendar month
|
||||
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01';
|
||||
const isSameMonth = (date: string | null | undefined) =>
|
||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Not signed in.');
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) return jsonError(500, 'Server is misconfigured.');
|
||||
|
||||
const { data: profile } = await sb
|
||||
.from('user_profiles')
|
||||
.select('tier, scan_count, scan_reset_date')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const tier = (profile?.tier as 'free' | 'analyst' | 'desk') ?? 'free';
|
||||
const usedThisMonth = isSameMonth(profile?.scan_reset_date) ? (profile?.scan_count ?? 0) : 0;
|
||||
const remaining = tier === 'free' ? Math.max(0, FREE_LIMIT - usedThisMonth) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
used_this_month: usedThisMonth,
|
||||
remaining,
|
||||
limit: tier === 'free' ? FREE_LIMIT : null,
|
||||
reset_date: monthKey(),
|
||||
period: 'monthly',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ALLOWED_LISTS = new Set(['merch', 'ledger-book', 'The Line', 'The Edge', 'The Correlation', 'The System', 'general']);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: { email?: string; list?: string; honeypot?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
// Honeypot — silently accept then drop
|
||||
if (body.honeypot) return NextResponse.json({ ok: true });
|
||||
|
||||
if (!body.email || !EMAIL_RE.test(body.email)) {
|
||||
return jsonError(400, 'Enter a valid email.');
|
||||
}
|
||||
const list = body.list && ALLOWED_LISTS.has(body.list) ? body.list : 'general';
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (sb) {
|
||||
await sb
|
||||
.from('waitlist_signups')
|
||||
.insert({ email: body.email.toLowerCase(), list, source: 'web' })
|
||||
.select()
|
||||
.maybeSingle();
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, list });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { verifyWebhookSignature, type NexaPayWebhookEvent, type NexaPayTier } from '@/services/nexapay';
|
||||
import { sendPaymentReceipt } from '@/services/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// We need the raw body to verify the HMAC signature.
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const signature = req.headers.get('x-nexapay-signature');
|
||||
|
||||
if (!verifyWebhookSignature(rawBody, signature)) {
|
||||
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let event: NexaPayWebhookEvent;
|
||||
try {
|
||||
event = JSON.parse(rawBody) as NexaPayWebhookEvent;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) {
|
||||
console.error('[nexapay webhook] Supabase service role not configured');
|
||||
return NextResponse.json({ error: 'misconfigured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const userId = event.data.metadata?.userId;
|
||||
const tier = event.data.metadata?.tier as NexaPayTier | undefined;
|
||||
const founderPricing = event.data.metadata?.founderPricing === 'true';
|
||||
|
||||
if (!userId || !tier) {
|
||||
return NextResponse.json({ ok: true, ignored: 'missing metadata' });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment.succeeded': {
|
||||
const subscription_end = new Date();
|
||||
subscription_end.setUTCDate(subscription_end.getUTCDate() + 30);
|
||||
|
||||
const { error } = await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
tier,
|
||||
subscription_status: 'active',
|
||||
subscription_start: new Date().toISOString(),
|
||||
subscription_end: subscription_end.toISOString(),
|
||||
cancel_at_period_end: false,
|
||||
founder_pricing: founderPricing,
|
||||
nexapay_customer_id: event.data.customer_id ?? null,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('[nexapay webhook] update failed', error);
|
||||
return NextResponse.json({ error: 'update_failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget receipt email. Don't block the webhook ACK.
|
||||
const { data: profileRow } = await sb
|
||||
.from('user_profiles')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (profileRow?.email) {
|
||||
void sendPaymentReceipt(profileRow.email, {
|
||||
tier,
|
||||
amount: `$${(event.data.amount / 100).toFixed(2)}`,
|
||||
renewsAt: subscription_end.toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.failed': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({ subscription_status: 'grace_period' })
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'payment.refunded':
|
||||
case 'subscription.canceled': {
|
||||
await sb
|
||||
.from('user_profiles')
|
||||
.update({
|
||||
subscription_status: 'canceled',
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
.eq('id', userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
|
||||
function CallbackInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Supabase JS picks up the access_token from the URL fragment automatically
|
||||
// (detectSessionInUrl: true). We wait for the session to materialize and
|
||||
// then route forward.
|
||||
const sb = getBrowserSupabase();
|
||||
if (!sb) {
|
||||
router.replace('/login?error=auth-not-configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const next = search.get('next') || '/dashboard';
|
||||
|
||||
const { data: sub } = sb.auth.onAuthStateChange((event) => {
|
||||
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED' || event === 'INITIAL_SESSION') {
|
||||
sb.auth.getSession().then(({ data }) => {
|
||||
if (data.session?.access_token) {
|
||||
// Keep the legacy localStorage token in sync for any older fetch
|
||||
// helpers that still read `sb-token` directly.
|
||||
localStorage.setItem('sb-token', data.session.access_token);
|
||||
if (data.session.refresh_token) {
|
||||
localStorage.setItem('sb-refresh-token', data.session.refresh_token);
|
||||
}
|
||||
router.replace(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: if no auth event fires within 4s, bail to login.
|
||||
const timer = setTimeout(() => router.replace('/login?error=callback-timeout'), 4000);
|
||||
return () => {
|
||||
sub.subscription.unsubscribe();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [router, search]);
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Signing you in…
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CallbackInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
const post = getPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return {
|
||||
title: `${post.title} — BetonBLK Blog`,
|
||||
title: `${post.title} — VYNDR Blog`,
|
||||
description: post.description,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
@@ -62,7 +62,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
datePublished: post.date,
|
||||
author: { '@type': 'Organization', name: 'BetonBLK' },
|
||||
author: { '@type': 'Organization', name: 'VYNDR' },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getAllPosts } from '@/lib/blog';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog — BetonBLK',
|
||||
description: 'Betting strategy, prop analysis breakdowns, and product updates from BetonBLK.',
|
||||
title: 'Blog — VYNDR',
|
||||
description: 'Betting strategy, prop analysis breakdowns, and product updates from VYNDR.',
|
||||
};
|
||||
|
||||
export default function BlogIndex() {
|
||||
@@ -23,7 +23,7 @@ export default function BlogIndex() {
|
||||
<a
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="block p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--accent)] transition"
|
||||
className="block p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--cyan)] transition"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--text-muted)] mb-2">
|
||||
<time>{post.date}</time>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
sport: Sport;
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
ab_grade_count?: number;
|
||||
injury_note?: string;
|
||||
}
|
||||
|
||||
interface TopGrade {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Sport;
|
||||
grade: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface ParlayLegStat {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Sport;
|
||||
grade?: string;
|
||||
parlay_count: number;
|
||||
}
|
||||
|
||||
interface RecentScan {
|
||||
id: string;
|
||||
player_name: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
sport: Sport;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(null);
|
||||
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
|
||||
|
||||
// Gate
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/dashboard');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
// Fetch slate when sport changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setGames(null);
|
||||
setTopGrades(null);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
|
||||
fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })),
|
||||
]).then(([gamesData, gradesData]) => {
|
||||
if (cancelled) return;
|
||||
setGames(Array.isArray(gamesData?.games) ? gamesData.games : []);
|
||||
setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
// Most parlayed + recent scans don't depend on sport
|
||||
useEffect(() => {
|
||||
fetch('/api/props/most-parlayed')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setMostParlayed(Array.isArray(data?.props) ? data.props.slice(0, 5) : []))
|
||||
.catch(() => setMostParlayed([]));
|
||||
|
||||
if (user) {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
fetch('/api/user/recent-scans', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setRecentScans(Array.isArray(data?.scans) ? data.scans.slice(0, 5) : []))
|
||||
.catch(() => setRecentScans([]));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const gameCountsBySport = useMemo(() => {
|
||||
// Updated whenever the sport's slate refreshes. We only know counts for
|
||||
// the current sport — others show a dash until clicked.
|
||||
return { [sport]: games?.length ?? 0 } as Partial<Record<Sport, number>>;
|
||||
}, [sport, games]);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstTimer = recentScans?.length === 0;
|
||||
const slateEmpty = games?.length === 0;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
{/* Top welcome strip */}
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 24, gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
Tonight's slate
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 4 }}>
|
||||
{new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }).toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
{tier === 'free' && scansRemaining != null && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{scansRemaining}/5 READS · MO
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sport tabs */}
|
||||
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
|
||||
{SPORT_TABS.map((s) => {
|
||||
const active = s === sport;
|
||||
const count = gameCountsBySport[s];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setSport(s)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${active ? SPORT_COLOR[s] : 'transparent'}`,
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{s}{' '}
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginLeft: 4 }}>
|
||||
{active && count != null ? `(${count})` : '·'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top grades horizontal scroll */}
|
||||
<Section
|
||||
title="Top grades tonight"
|
||||
subtitle="The A-tier picks. Tap to read or add to parlay."
|
||||
>
|
||||
{topGrades === null ? (
|
||||
<SkeletonRow />
|
||||
) : topGrades.length === 0 ? (
|
||||
<p style={emptyCopy}>No grades yet. The model is waiting on lines.</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
padding: '4px 4px 12px',
|
||||
scrollSnapType: 'x mandatory',
|
||||
}}
|
||||
>
|
||||
{topGrades.map((g, i) => (
|
||||
<button
|
||||
key={`${g.player}-${g.stat}-${i}`}
|
||||
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
|
||||
className="surface diagonal-cut surface-hover"
|
||||
style={{
|
||||
minWidth: 200,
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
scrollSnapAlign: 'start',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<SportPill sport={g.sport} />
|
||||
<GradePill grade={g.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Tonight's games */}
|
||||
<Section title={`Tonight's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} games tipping`}>
|
||||
{games === null ? (
|
||||
<SkeletonRow stacked />
|
||||
) : games.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No games on the slate tonight.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
|
||||
Check the Ledger to review past grades, or explore the Leaderboard.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{games.map((g, idx) => (
|
||||
<a
|
||||
key={g.id}
|
||||
href={`/game/${g.id}`}
|
||||
className={`surface surface-hover diagonal-cut animate-fade-up stagger-${(idx % 6) + 1}`}
|
||||
style={{
|
||||
padding: 20,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
border: isFirstTimer && idx === 0 ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
boxShadow: isFirstTimer && idx === 0 ? '0 0 0 4px var(--accent-glow)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>
|
||||
{g.away} @ {g.home}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{formatTime(g.start_time)}
|
||||
{g.injury_note ? ` · ${g.injury_note}` : ' · Full squad'}
|
||||
{g.ab_grade_count != null ? ` · ${g.ab_grade_count} A/B grades` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{g.prop_count ?? '—'} props →
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Most parlayed tonight */}
|
||||
<Section title="Most parlayed tonight" subtitle="What other bettors are stacking." right={<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>🔥 TRENDING</span>}>
|
||||
{mostParlayed === null ? (
|
||||
<SkeletonRow />
|
||||
) : mostParlayed.length === 0 ? (
|
||||
<p style={emptyCopy}>No parlays built yet tonight. Be the first.</p>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{mostParlayed.map((p, i) => (
|
||||
<li key={`${p.player}-${p.stat}-${i}`}
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>🔥 {p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{p.sport} · {p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
{p.grade && <GradePill grade={p.grade} />}
|
||||
<button
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => {
|
||||
addLeg({
|
||||
sport: p.sport,
|
||||
player: p.player,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
direction: p.direction,
|
||||
grade: p.grade ?? 'B',
|
||||
confidence: 55,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
>
|
||||
+ Parlay
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Recent scans OR first-timer onboarding */}
|
||||
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
|
||||
{recentScans === null ? (
|
||||
<SkeletonRow />
|
||||
) : recentScans.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut-strong"
|
||||
style={{
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--accent-light)',
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--grade-a)', marginBottom: 12 }}>
|
||||
WELCOME TO THE LEDGER
|
||||
</p>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
|
||||
Tonight's slate is loaded. {games?.length ?? 0} {games?.length === 1 ? 'game' : 'games'} across 3 sports.
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}>
|
||||
Pick a game and read your first prop — it's on us.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ padding: '12px 24px' }}>
|
||||
Run a read →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{recentScans.map((s) => (
|
||||
<li key={s.id}
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{s.player_name}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{s.sport} · {s.direction} {s.line} {s.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<GradePill grade={s.grade} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, right, children }: { title: string; subtitle: string | null; right?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 2 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SportPill({ sport }: { sport: Sport }) {
|
||||
return (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[sport]}1F`,
|
||||
color: SPORT_COLOR[sport],
|
||||
}}
|
||||
>
|
||||
{sport}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow({ stacked = false }: { stacked?: boolean }) {
|
||||
if (stacked) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 80,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
height: 110,
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyCopy: React.CSSProperties = {
|
||||
padding: '24px 16px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 14,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const request = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setError('');
|
||||
if (!email) return setError('Enter your email.');
|
||||
setBusy(true);
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) {
|
||||
setBusy(false);
|
||||
return setError('Auth is not configured.');
|
||||
}
|
||||
const { error: err } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/callback?next=/profile`,
|
||||
});
|
||||
setBusy(false);
|
||||
if (err) return setError(err.message);
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
{!sent ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Reset your password</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-1)', fontSize: 13, marginBottom: 24 }}>
|
||||
Enter your email. We'll send you a reset link.
|
||||
</p>
|
||||
<form onSubmit={request} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="lbl" style={{ display: 'block', marginBottom: 6 }}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Sending…' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<ConfirmationBlock email={email} onResend={request} busy={busy} />
|
||||
)}
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-1)', marginTop: 20 }}>
|
||||
<a href="/login" style={{ color: 'var(--text-1)' }}>← Back to sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationBlock({ email, onResend, busy }: { email: string; onResend: () => void; busy: boolean }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}>
|
||||
<EnvelopeIcon />
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Check your inbox</h2>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 320 }}>
|
||||
We sent a reset link to <span className="mono" style={{ color: 'var(--text-0)' }}>{email}</span>. It expires in 1 hour.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="btn-ghost"
|
||||
style={{ marginTop: 8, fontSize: 13 }}
|
||||
>
|
||||
{busy ? 'Resending…' : "Didn't get it? Resend"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvelopeIcon() {
|
||||
return (
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="var(--grade-a)" strokeWidth="1.5" />
|
||||
<path d="M3 7l9 6 9-6" stroke="var(--grade-a)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Player {
|
||||
name: string;
|
||||
position?: string;
|
||||
injury_status?: 'OUT' | 'DOUBTFUL' | 'QUESTIONABLE' | null;
|
||||
}
|
||||
|
||||
interface GameDetail {
|
||||
id: string;
|
||||
sport: Sport;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
spread?: number;
|
||||
spread_favorite?: 'home' | 'away';
|
||||
total?: number;
|
||||
moneyline_home?: number;
|
||||
moneyline_away?: number;
|
||||
away_lineup?: Player[];
|
||||
home_lineup?: Player[];
|
||||
pace?: number;
|
||||
pace_rank?: number;
|
||||
matchup_notes?: string[];
|
||||
}
|
||||
|
||||
interface PropEntry {
|
||||
id: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: Record<string, string>;
|
||||
kill_conditions?: { code: string; reason: string }[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
alt_lines?: { line: number; grade: string; hit_rate?: number }[];
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function GamePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [props, setProps] = useState<PropEntry[] | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace(`/login?next=/game/${id}`);
|
||||
}, [authLoading, user, id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError('');
|
||||
Promise.all([
|
||||
fetch(`/api/games/${id}`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/games/${id}/props`).then((r) => r.json()).catch(() => ({ props: [] })),
|
||||
]).then(([g, p]) => {
|
||||
if (cancelled) return;
|
||||
if (!g || g.error) {
|
||||
setError(g?.error || 'Game not found.');
|
||||
return;
|
||||
}
|
||||
setGame(g);
|
||||
setProps(Array.isArray(p?.props) ? p.props : []);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (authLoading || !user || (!game && !error)) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading game…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
||||
</p>
|
||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
{/* Back nav */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12, marginBottom: 16 }}
|
||||
>
|
||||
← Slate
|
||||
</button>
|
||||
|
||||
{/* Game header */}
|
||||
<header
|
||||
className="surface diagonal-cut-strong animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 16,
|
||||
borderColor: SPORT_COLOR[game.sport],
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[game.sport]}1F`,
|
||||
color: SPORT_COLOR[game.sport],
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{game.sport}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{formatTime(game.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
|
||||
{game.away} <span style={{ color: 'var(--text-tertiary)' }}>@</span> {game.home}
|
||||
</h1>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{game.spread != null && (
|
||||
<Stat label="Spread" value={`${game.spread_favorite === 'home' ? game.home : game.away} ${game.spread}`} />
|
||||
)}
|
||||
{game.total != null && <Stat label="Total" value={game.total.toString()} />}
|
||||
{game.moneyline_home != null && (
|
||||
<Stat label="Moneyline" value={`${game.home} ${formatOdds(game.moneyline_home)} / ${game.away} ${formatOdds(game.moneyline_away ?? 0)}`} />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Starting lineups */}
|
||||
{(game.away_lineup?.length || game.home_lineup?.length) && (
|
||||
<Card title="Starting lineups">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Lineup teamName={game.away} players={game.away_lineup ?? []} />
|
||||
<Lineup teamName={game.home} players={game.home_lineup ?? []} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Matchup stats */}
|
||||
{game.matchup_notes && game.matchup_notes.length > 0 && (
|
||||
<Card title="Key matchup stats">
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{game.matchup_notes.map((note, i) => (
|
||||
<li key={i} style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
· {note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Props list */}
|
||||
<Card title={`All props for this game`} subtitle={`${props?.length ?? 0} graded`}>
|
||||
{props === null ? (
|
||||
<p style={{ color: 'var(--text-tertiary)' }}>Loading props…</p>
|
||||
) : props.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
No props posted for this game yet. Books usually open player props 2–3 hours before tip. Check back closer to game time.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{props.map((p) => {
|
||||
const isOpen = expanded === p.id;
|
||||
return (
|
||||
<li key={p.id} className="surface" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(isOpen ? null : p.id)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<GradePill grade={p.grade} />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addLeg({
|
||||
sport: game.sport,
|
||||
player: p.player,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
direction: p.direction,
|
||||
grade: p.grade,
|
||||
confidence: p.confidence ?? 55,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||||
aria-label="Add to parlay"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ padding: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<GradeCard
|
||||
sport={game.sport}
|
||||
player={p.player}
|
||||
stat={p.stat}
|
||||
line={p.line}
|
||||
direction={p.direction}
|
||||
grade={p.grade}
|
||||
projection={p.projection}
|
||||
confidence={p.confidence}
|
||||
sample_size={p.sample_size}
|
||||
factors={p.factors}
|
||||
alt_lines={p.alt_lines}
|
||||
kill_conditions={p.kill_conditions}
|
||||
reasoning={p.reasoning}
|
||||
historical_hit_rate={p.historical_hit_rate}
|
||||
tier={tier}
|
||||
onUpgradeClick={(target) => router.push(`/api/checkout?tier=${target}`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<a
|
||||
href={`/scan?sport=${game.sport}`}
|
||||
className="btn-primary"
|
||||
style={{ padding: '12px 24px' }}
|
||||
>
|
||||
Read a custom prop →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<header style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{subtitle}</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-surface)', borderRadius: 10, border: '1px solid var(--border)' }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 13, fontWeight: 700, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lineup({ teamName, players }: { teamName: string; players: Player[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||
{teamName.toUpperCase()}
|
||||
</div>
|
||||
<ul style={{ display: 'grid', gap: 6 }}>
|
||||
{players.map((p) => (
|
||||
<li key={p.name} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 8 }}>
|
||||
<span>{p.name}</span>
|
||||
<span className="mono" style={{
|
||||
fontSize: 11,
|
||||
color: p.injury_status === 'OUT' ? 'var(--grade-d)' : p.injury_status ? 'var(--grade-c)' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{p.injury_status ? `${p.injury_status === 'OUT' ? '❌' : '⚠'} ${p.injury_status}` : p.position}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOdds(odds: number): string {
|
||||
if (!odds) return '—';
|
||||
return odds > 0 ? `+${odds}` : `${odds}`;
|
||||
}
|
||||
+746
-15
@@ -1,24 +1,755 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
VYNDR Design System
|
||||
Bloomberg terminal + ESPN broadcast + glitch-in-the-matrix.
|
||||
"The terminal at sportsbook prices."
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--card: #141414;
|
||||
--border: #222222;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888888;
|
||||
--grade-a: #22c55e;
|
||||
--grade-b: #eab308;
|
||||
--grade-c: #f97316;
|
||||
--grade-d: #ef4444;
|
||||
--accent: #3b82f6;
|
||||
/* Surfaces — powered-on screen, slightly warmer than pure black */
|
||||
--bg-0: #06060B;
|
||||
--bg-1: #0E0E16;
|
||||
--bg-2: #15151F;
|
||||
--bg-3: #1A1A26;
|
||||
|
||||
/* Borders */
|
||||
--border: #1E1E2E;
|
||||
--border-hi: #2A2A3E;
|
||||
|
||||
/* Text */
|
||||
--text-0: #E8E8F0;
|
||||
--text-1: #7A7A8E;
|
||||
--text-2: #4A4A5E;
|
||||
|
||||
/* Brand accent — deep forest, button green, glow */
|
||||
--acc-0: #0F3D2E;
|
||||
--acc-1: #1A5A42;
|
||||
--acc-glow: rgba(15, 61, 46, 0.25);
|
||||
|
||||
/* Grade tiers — the most important colors */
|
||||
--grade-aplus: #00FFB8;
|
||||
--grade-a: #00D4A0;
|
||||
--grade-b: #4A9EFF;
|
||||
--grade-c: #FFB347;
|
||||
--grade-d: #FF5252;
|
||||
|
||||
/* Sport tints */
|
||||
--nba: #E94B3C;
|
||||
--mlb: #1E90FF;
|
||||
--wnba: #F7944A;
|
||||
|
||||
/* Special — Vendetta crimson; kill conditions only */
|
||||
--crimson: #8B0000;
|
||||
|
||||
/* Signals */
|
||||
--danger: var(--grade-d);
|
||||
--warning: var(--grade-c);
|
||||
--success: var(--grade-a);
|
||||
|
||||
/* Geometry */
|
||||
--diagonal-angle: -2deg;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Glitch intensity — 0..1, can be tuned per-section */
|
||||
--glitch: 0.5;
|
||||
--scan-opacity: calc(0.04 + 0.06 * var(--glitch));
|
||||
--grain-opacity: calc(0.02 + 0.02 * var(--glitch));
|
||||
--rgb-shift: calc(0.5px + 1.5px * var(--glitch));
|
||||
--slash-opacity: calc(0.06 + 0.08 * var(--glitch));
|
||||
--sweep-opacity: calc(0.08 + 0.12 * var(--glitch));
|
||||
|
||||
/* ── Legacy aliases — every existing component still resolves ── */
|
||||
--bg-primary: var(--bg-0);
|
||||
--bg-surface: var(--bg-1);
|
||||
--bg-surface-hover: var(--bg-2);
|
||||
--bg-elevated: var(--bg-3);
|
||||
--border-focus: var(--border-hi);
|
||||
--text-primary: var(--text-0);
|
||||
--text-secondary: var(--text-1);
|
||||
--text-tertiary: var(--text-2);
|
||||
--accent: var(--acc-0);
|
||||
--accent-light: var(--acc-1);
|
||||
--accent-glow: var(--acc-glow);
|
||||
--bg: var(--bg-0);
|
||||
--card: var(--bg-1);
|
||||
--card-hover: var(--bg-2);
|
||||
--border-light: var(--border-hi);
|
||||
--text: var(--text-0);
|
||||
--text-muted: var(--text-1);
|
||||
--text-dim: var(--text-2);
|
||||
--cyan: var(--grade-a);
|
||||
--cyan-hover: var(--grade-aplus);
|
||||
--cyan-dim: rgba(0, 212, 160, 0.10);
|
||||
--kill: var(--crimson);
|
||||
--forest: var(--acc-0);
|
||||
--forest-dark: #0A2A20;
|
||||
--forest-light: var(--acc-1);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Base
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg-primary);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-0);
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
.font-mono,
|
||||
.mono {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.num {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lbl {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-focus);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
The Diagonal Cut — signature visual motif
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.diagonal-cut {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.diagonal-cut::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(var(--diagonal-angle), transparent 0%, var(--accent-glow) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.diagonal-cut > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.diagonal-cut-strong::before {
|
||||
background: linear-gradient(var(--diagonal-angle), transparent 0%, rgba(26, 74, 58, 0.20) 100%);
|
||||
}
|
||||
|
||||
/* Radial accent glow — for hero backgrounds */
|
||||
.radial-glow {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 50% 30%, var(--accent-glow), transparent 70%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Surfaces
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.surface {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: transform 200ms ease, border-color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.surface-hover:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--border-focus);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.surface-elevated {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-focus);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Buttons
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent-light);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease, transform 100ms ease, box-shadow 200ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-light);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 200ms ease, background 200ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--border-focus);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Inputs
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
transition: border-color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-light);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Grade tier helpers
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.grade-a-tier { color: var(--grade-a); }
|
||||
.grade-b-tier { color: var(--grade-b); }
|
||||
.grade-c-tier { color: var(--grade-c); }
|
||||
.grade-d-tier { color: var(--grade-d); }
|
||||
|
||||
.grade-a-bg { background: rgba(0, 200, 150, 0.10); border-color: rgba(0, 200, 150, 0.30); }
|
||||
.grade-b-bg { background: rgba(74, 158, 255, 0.10); border-color: rgba(74, 158, 255, 0.30); }
|
||||
.grade-c-bg { background: rgba(255, 179, 71, 0.10); border-color: rgba(255, 179, 71, 0.30); }
|
||||
.grade-d-bg { background: rgba(255, 107, 107, 0.10); border-color: rgba(255, 107, 107, 0.30); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Animations
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes grade-reveal {
|
||||
0% { transform: scale(0.85); opacity: 0; }
|
||||
60% { transform: scale(1.04); opacity: 1; }
|
||||
100% { transform: scale(1.00); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-grade {
|
||||
animation: grade-reveal 400ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes blur-pulse {
|
||||
0%, 100% { opacity: 0.65; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.animate-blur-pulse {
|
||||
animation: blur-pulse 3s ease-in-out 1;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer-loading {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent) 0%,
|
||||
var(--accent-light) 50%,
|
||||
var(--accent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes phosphor-pulse {
|
||||
0%, 100% { opacity: 0.6; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 400ms ease-out both;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 50ms; }
|
||||
.stagger-2 { animation-delay: 100ms; }
|
||||
.stagger-3 { animation-delay: 150ms; }
|
||||
.stagger-4 { animation-delay: 200ms; }
|
||||
.stagger-5 { animation-delay: 250ms; }
|
||||
.stagger-6 { animation-delay: 300ms; }
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.animate-ticker {
|
||||
animation: ticker-scroll 60s linear infinite;
|
||||
}
|
||||
|
||||
.animate-ticker:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Tier blur (the conversion engine)
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.tier-locked {
|
||||
position: relative;
|
||||
filter: blur(8px);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
animation: blur-pulse 3s ease-in-out 1;
|
||||
}
|
||||
|
||||
.tier-locked-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(10, 10, 15, 0.6) 60%, rgba(10, 10, 15, 0.85) 100%);
|
||||
border-radius: inherit;
|
||||
z-index: 2;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Utilities
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.text-balance { text-wrap: balance; }
|
||||
.text-pretty { text-wrap: pretty; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Texture layers — seasoning, not the meal
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.tex-scan {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.tex-scan::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255,255,255,var(--scan-opacity)) 0px,
|
||||
rgba(255,255,255,var(--scan-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
z-index: 2;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.tex-sweep {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
.tex-sweep::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 140px;
|
||||
top: -140px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
rgba(0, 255, 184, var(--sweep-opacity)) 50%,
|
||||
transparent
|
||||
);
|
||||
z-index: 1;
|
||||
animation: crt-sweep 7s ease-in-out infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
@keyframes crt-sweep {
|
||||
0% { transform: translateY(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(120vh); opacity: 0; }
|
||||
}
|
||||
|
||||
.tex-vignette {
|
||||
position: relative;
|
||||
}
|
||||
.tex-vignette::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.tex-slash {
|
||||
position: relative;
|
||||
}
|
||||
.tex-slash::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
-2deg,
|
||||
transparent 0%,
|
||||
rgba(0, 212, 160, var(--slash-opacity)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tex-grain::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.9 0 0 0 0 0.9 0 0 0 0 1 0 0 0 0.45 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
opacity: var(--grain-opacity);
|
||||
z-index: 999;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Page-level CRT vignette, scales with --glitch */
|
||||
body.tex-grain::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse 110% 90% at 50% 50%, transparent 50%, rgba(0,0,0,calc(0.15 + 0.20 * var(--glitch))) 100%);
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
RGB split — broadcast misalignment effect
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.rgb-split {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.rgb-split::before,
|
||||
.rgb-split::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rgb-split::before {
|
||||
color: rgba(255, 60, 60, 0.55);
|
||||
transform: translateX(calc(-1 * var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.rgb-split::after {
|
||||
color: rgba(60, 120, 255, 0.55);
|
||||
transform: translateX(var(--rgb-shift));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Wordmark — VYNDR with RGB-split letters, green-glow R, blinking cursor
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.wordmark {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.10em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
.wm-letter {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.wm-letter::before,
|
||||
.wm-letter::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.wm-letter::before {
|
||||
color: rgba(255, 60, 60, 0.55);
|
||||
transform: translateX(calc(-1px - var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.wm-letter::after {
|
||||
color: rgba(60, 120, 255, 0.55);
|
||||
transform: translateX(calc(1px + var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.wordmark .vynd { color: var(--text-0); }
|
||||
.wordmark .r {
|
||||
color: var(--grade-a);
|
||||
text-shadow: 0 0 calc(var(--wm-size, 32px) * 0.25) rgba(0, 212, 160, 0.6);
|
||||
position: relative;
|
||||
}
|
||||
.wordmark .r::before { color: rgba(255, 60, 60, 0.45); }
|
||||
.wordmark .r::after { color: rgba(0, 255, 184, 0.60); }
|
||||
|
||||
.wm-cursor {
|
||||
display: inline-block;
|
||||
width: 0.16em;
|
||||
height: 0.9em;
|
||||
margin-left: 0.18em;
|
||||
background: var(--grade-a);
|
||||
box-shadow: 0 0 calc(var(--wm-size, 32px) * 0.3) rgba(0, 212, 160, 0.8);
|
||||
animation: cursor-blink 1.1s steps(2) infinite;
|
||||
}
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.wm-anim { animation: wm-tear 5.5s ease-in-out infinite; }
|
||||
@keyframes wm-tear {
|
||||
0%, 4%, 100% { transform: translateX(0); }
|
||||
5% { transform: translateX(2px) skewX(-2deg); filter: hue-rotate(20deg); }
|
||||
6% { transform: translateX(-1px); }
|
||||
7% { transform: translateX(0); filter: none; }
|
||||
47% { transform: translateX(0); }
|
||||
48% { transform: translateX(-2px) skewX(1deg); }
|
||||
49% { transform: translateX(1px); }
|
||||
50% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Glitch text — occasional horizontal tear, broadcast tinted ghosts
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.glitch-text {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.glitch-text::before,
|
||||
.glitch-text::after {
|
||||
content: attr(data-text);
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.glitch-text::before {
|
||||
color: rgba(255, 60, 60, 0.7);
|
||||
transform: translateX(calc(-1px - var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
animation: glitch-shift-r 4s steps(40) infinite;
|
||||
}
|
||||
.glitch-text::after {
|
||||
color: rgba(60, 180, 255, 0.7);
|
||||
transform: translateX(calc(1px + var(--rgb-shift)));
|
||||
mix-blend-mode: screen;
|
||||
animation: glitch-shift-b 4s steps(40) infinite reverse;
|
||||
}
|
||||
@keyframes glitch-shift-r {
|
||||
0%, 100% { clip-path: inset(0 0 0 0); transform: translateX(calc(-1px - var(--rgb-shift))); }
|
||||
92% { clip-path: inset(20% 0 60% 0); transform: translateX(-3px); }
|
||||
94% { clip-path: inset(60% 0 10% 0); transform: translateX(2px); }
|
||||
96% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
@keyframes glitch-shift-b {
|
||||
0%, 100% { clip-path: inset(0 0 0 0); }
|
||||
93% { clip-path: inset(45% 0 35% 0); transform: translateX(3px); }
|
||||
95% { clip-path: inset(10% 0 70% 0); transform: translateX(-2px); }
|
||||
97% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Transmission badge, frequency badge, phosphor underline
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
.transmission {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--grade-a);
|
||||
background: rgba(0, 212, 160, 0.06);
|
||||
border: 1px solid rgba(0, 212, 160, 0.30);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.transmission .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--grade-a);
|
||||
box-shadow: 0 0 8px var(--grade-a);
|
||||
animation: pulse-live 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-live {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
.freq-badge {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.freq-badge .ch { color: var(--grade-a); font-weight: 700; }
|
||||
|
||||
.phosphor-line {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--grade-a), transparent);
|
||||
box-shadow: 0 0 8px rgba(0, 212, 160, 0.60);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The V watermark — Vendetta nod, hero background */
|
||||
.v-watermark {
|
||||
position: absolute;
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
font-weight: 800;
|
||||
font-size: 70vmin;
|
||||
line-height: 0.8;
|
||||
color: var(--acc-0);
|
||||
opacity: 0.06;
|
||||
letter-spacing: -0.05em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* CRT phosphor glow utility for grade letters */
|
||||
.grade-glow-aplus { color: var(--grade-aplus); text-shadow: 0 0 18px var(--grade-aplus), 0 0 36px rgba(0, 255, 184, 0.40); }
|
||||
.grade-glow-a { color: var(--grade-a); text-shadow: 0 0 14px rgba(0, 212, 160, 0.70), 0 0 30px rgba(0, 212, 160, 0.30); }
|
||||
.grade-glow-b { color: var(--grade-b); text-shadow: 0 0 12px rgba(74, 158, 255, 0.60), 0 0 24px rgba(74, 158, 255, 0.20); }
|
||||
.grade-glow-c { color: var(--grade-c); text-shadow: 0 0 12px rgba(255, 179, 71, 0.55); }
|
||||
.grade-glow-d { color: var(--grade-d); text-shadow: 0 0 12px rgba(255, 82, 82, 0.55); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type Signal = {
|
||||
id: string;
|
||||
ts: string;
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
severity: 'critical' | 'notable' | 'info';
|
||||
type: 'evolution' | 'coaching' | 'cascade' | 'abs' | 'line_movement';
|
||||
title: string;
|
||||
detail: string;
|
||||
players?: string[];
|
||||
};
|
||||
|
||||
const SEVERITY: Record<Signal['severity'], { dot: string; label: string }> = {
|
||||
critical: { dot: '#FF4757', label: 'CRITICAL' },
|
||||
notable: { dot: '#FFB347', label: 'NOTABLE' },
|
||||
info: { dot: '#4A9EFF', label: 'INFO' },
|
||||
};
|
||||
|
||||
const SPORT_COLOR: Record<Signal['sport'], string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const [signals, setSignals] = useState<Signal[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/intelligence');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/intelligence/feed')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setSignals(Array.isArray(data?.signals) ? data.signals : []))
|
||||
.catch(() => setSignals([]));
|
||||
}, []);
|
||||
|
||||
const locked = tier !== 'desk';
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading intelligence feed…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 760, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
Intelligence feed
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Real-time signals the books wish you didn't see. Evolution. Coaching shifts. Cascade effects. Line movement.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{signals === null ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading signals…</p>
|
||||
) : signals.length === 0 ? (
|
||||
<div className="surface" style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
QUIET NIGHT
|
||||
</p>
|
||||
<p>No signals yet tonight. The engine is watching. When something moves, you'll see it here first.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ol style={{ position: 'relative', display: 'grid', gap: 12, listStyle: 'none', padding: 0 }} className={locked ? 'tier-locked' : ''}>
|
||||
{signals.map((s, idx) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${(idx % 6) + 1}`}
|
||||
style={{ padding: 18, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: SEVERITY[s.severity].dot,
|
||||
boxShadow: `0 0 0 4px ${SEVERITY[s.severity].dot}33`,
|
||||
}}
|
||||
aria-label={SEVERITY[s.severity].label}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[s.sport]}1F`,
|
||||
color: SPORT_COLOR[s.sport],
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{s.sport}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: SEVERITY[s.severity].dot, letterSpacing: '0.08em' }}>
|
||||
{s.type.toUpperCase().replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
{formatRelative(s.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>{s.title}</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>{s.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{locked && (
|
||||
<div
|
||||
className="tier-locked-overlay"
|
||||
style={{ minHeight: 240 }}
|
||||
>
|
||||
<p style={{ fontSize: 15, fontWeight: 600 }}>
|
||||
Real-time intelligence is a Desk feature.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', maxWidth: 360 }}>
|
||||
Evolution alerts. Coaching shifts. Cascade effects. ABS strike zone intel. Line movement signals across the slate.
|
||||
</p>
|
||||
<a href="/api/checkout?tier=desk" className="btn-primary">
|
||||
Go Desk — $44.99/mo
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (!Number.isFinite(diff)) return '—';
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
+92
-23
@@ -1,14 +1,83 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import PostHogProvider from '@/components/PostHogProvider';
|
||||
import AuthProvider from '@/contexts/AuthContext';
|
||||
import ParlayProvider from '@/contexts/ParlayContext';
|
||||
import ExplainModeProvider from '@/contexts/ExplainModeContext';
|
||||
import Nav from '@/components/Nav';
|
||||
import ParlayTray from '@/components/ParlayTray';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import InstallPrompt from '@/components/InstallPrompt';
|
||||
import PushPrompt from '@/components/PushPrompt';
|
||||
import MFAPrompt from '@/components/MFAPrompt';
|
||||
import MFAChallenge from '@/components/MFAChallenge';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BetonBLK — AI-Powered Parlay Intelligence',
|
||||
description: 'Stop guessing. Start grading. BetonBLK scans your parlay in seconds with AI-powered prop analysis across DraftKings, FanDuel, and BetMGM.',
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
|
||||
title: {
|
||||
default: 'VYNDR — Sports Prop Intelligence',
|
||||
template: '%s · VYNDR',
|
||||
},
|
||||
description:
|
||||
"Grade NBA, MLB, and WNBA props with intelligence the books don't want you to have. Built in Detroit.",
|
||||
applicationName: 'VYNDR',
|
||||
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
|
||||
manifest: '/manifest.json',
|
||||
keywords: [
|
||||
'sports prop grading',
|
||||
'NBA prop bet analysis',
|
||||
'MLB prop intelligence',
|
||||
'WNBA prop grading',
|
||||
'parlay correlation analysis',
|
||||
'prop betting tools',
|
||||
],
|
||||
openGraph: {
|
||||
title: 'BetonBLK — AI-Powered Parlay Intelligence',
|
||||
description: 'Stop guessing. Start grading.',
|
||||
title: "VYNDR — Intelligence the books don't want you to have",
|
||||
description:
|
||||
'Read player props with Bayesian intelligence. See the factors. Know the kill conditions. Take the edge back.',
|
||||
url: 'https://vyndr.app',
|
||||
siteName: 'VYNDR',
|
||||
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'VYNDR — Sports Prop Intelligence' }],
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'VYNDR',
|
||||
description: 'The books have every advantage. We built this to give it back.',
|
||||
images: ['/og-image.png'],
|
||||
creator: '@getvyndr',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/favicon.ico', sizes: '32x32' },
|
||||
{ url: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'VYNDR',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#06060B',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -18,27 +87,27 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen antialiased">
|
||||
<nav className="fixed top-0 w-full z-50 border-b border-[var(--border)] bg-[var(--bg)]/80 backdrop-blur-md">
|
||||
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<a href="/" className="font-mono font-bold text-xl tracking-tight">
|
||||
Beton<span className="text-[var(--accent)]">BLK</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<a href="/blog" className="text-[var(--text-muted)] hover:text-white transition">Blog</a>
|
||||
<a href="/scan" className="text-[var(--text-muted)] hover:text-white transition">Scan</a>
|
||||
<a href="/tracker" className="text-[var(--text-muted)] hover:text-white transition">Tracker</a>
|
||||
<a href="/login" className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white text-sm font-medium hover:opacity-90 transition">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="pt-16">{children}</main>
|
||||
<body className="antialiased tex-grain">
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
<ExplainModeProvider>
|
||||
<ParlayProvider>
|
||||
<Nav />
|
||||
<main style={{ paddingTop: 64, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||
<ParlayTray />
|
||||
<BottomTabBar />
|
||||
<InstallPrompt />
|
||||
<PushPrompt />
|
||||
<MFAPrompt />
|
||||
<MFAChallenge />
|
||||
</ParlayProvider>
|
||||
</ExplainModeProvider>
|
||||
</AuthProvider>
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
|
||||
type TierFilter = 'ALL' | 'A' | 'B' | 'C';
|
||||
|
||||
interface LedgerEntry {
|
||||
id: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Exclude<Sport, 'ALL'>;
|
||||
grade: string;
|
||||
projection?: number;
|
||||
actual?: number;
|
||||
hit: boolean | null;
|
||||
miss_reason?: string;
|
||||
graded_at: string;
|
||||
}
|
||||
|
||||
interface AccuracyBucket {
|
||||
tier: string;
|
||||
hits: number;
|
||||
losses: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Exclude<Sport, 'ALL'>, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function LedgerPage() {
|
||||
const [sport, setSport] = useState<Sport>('ALL');
|
||||
const [tier, setTier] = useState<TierFilter>('ALL');
|
||||
const [entries, setEntries] = useState<LedgerEntry[] | null>(null);
|
||||
const [accuracy, setAccuracy] = useState<AccuracyBucket[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (sport !== 'ALL') params.set('sport', sport);
|
||||
if (tier !== 'ALL') params.set('tier', tier);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/ledger?${params}`).then((r) => r.json()).catch(() => ({ entries: [] })),
|
||||
fetch('/api/ledger/accuracy').then((r) => r.json()).catch(() => ({ buckets: [] })),
|
||||
]).then(([entriesData, accuracyData]) => {
|
||||
setEntries(Array.isArray(entriesData?.entries) ? entriesData.entries : []);
|
||||
setAccuracy(Array.isArray(accuracyData?.buckets) ? accuracyData.buckets : []);
|
||||
});
|
||||
}, [sport, tier]);
|
||||
|
||||
const overall = useMemo(() => {
|
||||
if (!accuracy?.length) return null;
|
||||
const totals = accuracy.reduce(
|
||||
(acc, b) => ({ h: acc.h + b.hits, l: acc.l + b.losses }),
|
||||
{ h: 0, l: 0 },
|
||||
);
|
||||
const total = totals.h + totals.l;
|
||||
if (!total) return null;
|
||||
return { hits: totals.h, losses: totals.l, pct: Math.round((totals.h / total) * 100) };
|
||||
}, [accuracy]);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
|
||||
The Ledger.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 16 }}>
|
||||
Every grade. Every result. No hiding. No deleting.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Accuracy header strip */}
|
||||
{accuracy && accuracy.length > 0 && (
|
||||
<section
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{accuracy.map((b) => (
|
||||
<AccuracyTile key={b.tier} bucket={b} />
|
||||
))}
|
||||
{overall && (
|
||||
<AccuracyTile
|
||||
bucket={{ tier: 'Overall', hits: overall.hits, losses: overall.losses, pct: overall.pct }}
|
||||
highlight
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
|
||||
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{entries === null ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
No grades yet.
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Read your first prop to start building your Ledger. Every grade you run shows up here, with the result.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
||||
Read a Prop →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
|
||||
style={{ padding: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[entry.sport]}1F`,
|
||||
color: SPORT_COLOR[entry.sport],
|
||||
}}
|
||||
>
|
||||
{entry.sport}
|
||||
</span>
|
||||
<GradePill grade={entry.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{entry.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 12 }}>
|
||||
{entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{entry.actual != null ? `Actual ${entry.actual}` : 'Pending'}
|
||||
</span>
|
||||
{entry.hit !== null && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: entry.hit ? 'var(--grade-a)' : 'var(--grade-d)',
|
||||
}}
|
||||
>
|
||||
{entry.hit ? 'HIT' : 'MISS'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.hit === false && entry.miss_reason && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--grade-d)',
|
||||
background: 'rgba(255,107,107,0.10)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,107,107,0.30)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontWeight: 700 }}>Why we missed:</strong> {entry.miss_reason}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccuracyTile({ bucket, highlight }: { bucket: AccuracyBucket; highlight?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
background: highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
border: highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{bucket.tier.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 800, color: highlight ? 'var(--grade-a)' : 'var(--text-primary)', marginTop: 4 }}>
|
||||
{bucket.pct}%
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||
{bucket.hits}-{bucket.losses}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginRight: 12 }}>
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
{options.map((o) => {
|
||||
const active = o === value;
|
||||
return (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => onChange(o)}
|
||||
className={active ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+108
-27
@@ -1,61 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackLogin } from '@/lib/analytics';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
function LoginInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') || '/dashboard';
|
||||
const { signIn, signInWithGoogle } = useAuth();
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
// TODO: Integrate with Supabase Auth
|
||||
// const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
setLoading(false);
|
||||
setError('Auth integration pending. Backend is ready.');
|
||||
const { error: err } = await signIn(email, password);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
trackLogin({ method: 'password' });
|
||||
router.replace(next);
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy(true);
|
||||
await signInWithGoogle();
|
||||
// Supabase redirects to provider; on return AuthContext picks up the session.
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-center mb-8">Log In</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Log in</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
|
||||
Welcome back. Let's read something.
|
||||
</p>
|
||||
|
||||
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div style={dividerStyle}>
|
||||
<span style={dividerLine} />
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>OR</span>
|
||||
<span style={dividerLine} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Email</label>
|
||||
<label className="mono" style={labelStyle}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Password</label>
|
||||
<label className="mono" style={labelStyle}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="input-field"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<a href="/forgot-password" style={{ display: 'inline-block', marginTop: 8, fontSize: 12, color: 'var(--text-secondary)', textDecoration: 'none' }}>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{error && <p className="text-[var(--grade-d)] text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-[var(--text-muted)] mt-6">
|
||||
Don't have an account? <a href="/signup" className="text-[var(--accent)] hover:underline">Sign up</a>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-secondary)', marginTop: 20 }}>
|
||||
No account?{' '}
|
||||
<a href={`/signup${next ? `?next=${encodeURIComponent(next)}` : ''}`} style={{ color: 'var(--grade-a)' }}>
|
||||
Sign up free
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const dividerLine: React.CSSProperties = {
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
list: 'api',
|
||||
title: 'API access',
|
||||
body: 'Programmatic access to the grading engine. Build your own dashboards, alerts, automations.',
|
||||
},
|
||||
{
|
||||
list: 'alerts',
|
||||
title: 'Custom alerts',
|
||||
body: 'Get pinged when specific players, stats, or matchups hit your threshold — by SMS, email, or webhook.',
|
||||
},
|
||||
{
|
||||
list: 'The Edge',
|
||||
title: 'The Edge — playbook',
|
||||
body: 'Long-form analysis of the model and how to deploy it in real bankroll management.',
|
||||
},
|
||||
{
|
||||
list: 'merch',
|
||||
title: 'Capsule drop',
|
||||
body: 'First merch run. 200 units. Founder access gets first pull.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [submitted, setSubmitted] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const joinList = async (list: string) => {
|
||||
if (honeypot) return;
|
||||
setError('');
|
||||
if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||
setError('Enter a valid email first.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, list }),
|
||||
});
|
||||
if (res.ok) setSubmitted((s) => new Set(s).add(list));
|
||||
} catch {
|
||||
setSubmitted((s) => new Set(s).add(list));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 880, margin: '0 auto', padding: '64px 16px 120px' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 12 }}>
|
||||
COMING SOON
|
||||
</p>
|
||||
<h1 style={{ fontSize: 'clamp(28px,4vw,40px)', fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 12 }}>
|
||||
The VYNDR Marketplace.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 16, maxWidth: 560, margin: '0 auto' }}>
|
||||
API access, custom alerts, deep analysis, and limited drops. Reserve your spot — these go to founder users first.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{ITEMS.map((item) => (
|
||||
<article key={item.list} className="surface diagonal-cut surface-hover" style={{ padding: 24 }}>
|
||||
<h3 style={{ fontSize: 17, fontWeight: 700, marginBottom: 8 }}>{item.title}</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{item.body}
|
||||
</p>
|
||||
{submitted.has(item.list) ? (
|
||||
<p className="mono" style={{ color: 'var(--grade-a)', fontSize: 13 }}>✓ On the list.</p>
|
||||
) : (
|
||||
<button onClick={() => joinList(item.list)} className="btn-ghost" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
Join waitlist
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="surface diagonal-cut" style={{ padding: 24, maxWidth: 460, margin: '0 auto' }}>
|
||||
<label className="mono" style={{ display: 'block', fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
YOUR EMAIL
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="you@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
{/* Honeypot — hidden from humans */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0 }}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden
|
||||
/>
|
||||
{error && <p style={{ fontSize: 12, color: 'var(--grade-d)', marginTop: 8 }}>{error}</p>}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12 }}>
|
||||
Tap any "Join waitlist" above and we'll add this email to that list. No spam. Cancel anytime.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from 'next/link';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
export const metadata = {
|
||||
title: '404 — Signal Lost',
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className="tex-scan"
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 144px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Wordmark size={28} />
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>TRANSMISSION INTERRUPTED</p>
|
||||
<h1
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 'clamp(80px, 16vw, 160px)',
|
||||
fontWeight: 800,
|
||||
color: 'var(--grade-c)',
|
||||
textShadow: '0 0 24px rgba(255, 179, 71, 0.6), 0 0 48px rgba(255, 179, 71, 0.25)',
|
||||
lineHeight: 0.9,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 480 }}>
|
||||
This page doesn't exist. The signal was lost.
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 480 }}>
|
||||
Check the URL, head back to the slate, or open the Ledger to review past grades.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<Link href="/dashboard" className="btn-primary">Back to Dashboard →</Link>
|
||||
<Link href="/ledger" className="btn-ghost">Open the Ledger</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+30
-2
@@ -1,16 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
import HowItWorks from '@/components/HowItWorks';
|
||||
import LivePropsStrip from '@/components/LivePropsStrip';
|
||||
import Features from '@/components/Features';
|
||||
import HowItWorks from '@/components/HowItWorks';
|
||||
import Pricing from '@/components/Pricing';
|
||||
import FAQ from '@/components/FAQ';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export default function Home() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) router.replace('/dashboard');
|
||||
}, [user, loading, router]);
|
||||
|
||||
// While we know the user is signed in we suppress the marketing
|
||||
// render to avoid a flicker before the redirect lands.
|
||||
if (loading || user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Loading the slate
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<LivePropsStrip />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy',
|
||||
description: 'How VYNDR handles your data. Plain English.',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
|
||||
{
|
||||
title: 'We never sell your personal data.',
|
||||
body: [
|
||||
'VYNDR Intelligence LLC does not sell, rent, or trade your personal data to anyone. Ever. Our business is a subscription to an analytics product — not a data resale operation.',
|
||||
],
|
||||
emphasized: true,
|
||||
},
|
||||
{
|
||||
title: 'Data we collect',
|
||||
body: [
|
||||
'Account data: email, password hash, age confirmation, signup timestamp.',
|
||||
'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.',
|
||||
'Payment data: NexaPay processes all card data — we never see or store your card. We retain a NexaPay customer ID and your subscription status.',
|
||||
'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'How we use your data',
|
||||
body: [
|
||||
'To provide the service: sign you in, gate reads by tier, deliver grades.',
|
||||
'To improve the model: aggregate, anonymized read data helps calibration. Individual reads are never used to identify you to third parties.',
|
||||
'Aggregate trend features: "Most Read Tonight" and "Most Parlayed Tonight" use anonymized counts across all users. Your individual reads are not exposed.',
|
||||
'To communicate: account confirmation, payment receipts, renewal reminders, critical service notices.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'What we do not do',
|
||||
body: [
|
||||
'We do not sell your personal data. Ever.',
|
||||
'We do not share your individual betting activity with sportsbooks, advertisers, or data brokers.',
|
||||
'We do not run ads. The business model is subscription, period.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Data retention',
|
||||
body: [
|
||||
'Account data: retained while your account is active. Deleted on request.',
|
||||
'Read history: retained indefinitely (anonymized after account deletion) for model calibration.',
|
||||
'Payment records: retained for 7 years per US tax and accounting requirements.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Cookies and analytics',
|
||||
body: [
|
||||
'Essential cookies: auth session, read counter (cannot be disabled — the service does not work without them).',
|
||||
'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
body: [
|
||||
'We send push notifications and emails based on your preferences in Settings. You can disable any notification type at any time. Transactional messages (receipts, password resets, security alerts) cannot be disabled while your account is active.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Your rights',
|
||||
body: [
|
||||
'Access: request a copy of your data at any time.',
|
||||
'Deletion: request account and data deletion at privacy@vyndr.app — we comply within 30 days.',
|
||||
'Correction: update your email and other profile data from the profile page.',
|
||||
'Export: request a JSON export of your read history.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Children',
|
||||
body: [
|
||||
'VYNDR is not for anyone under 21. We do not knowingly collect data from minors. If we discover an account belongs to a minor, we will delete it immediately.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Changes',
|
||||
body: [
|
||||
'If we make material changes to this policy, we will notify you by email at least 30 days before they take effect.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
body: [
|
||||
'Privacy questions or data requests: privacy@vyndr.app',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 720, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>Privacy Policy</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
EFFECTIVE: MAY 2026
|
||||
</p>
|
||||
</header>
|
||||
{SECTIONS.map((s) => (
|
||||
<section
|
||||
key={s.title}
|
||||
style={{
|
||||
marginBottom: 32,
|
||||
...(s.emphasized
|
||||
? {
|
||||
borderLeft: '3px solid var(--grade-a)',
|
||||
background: 'rgba(0, 212, 160, 0.04)',
|
||||
borderRadius: 4,
|
||||
padding: '12px 16px',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: s.emphasized ? 20 : 18, fontWeight: 700, marginBottom: 12 }}>{s.title}</h2>
|
||||
{s.body.map((p, i) => (
|
||||
<p
|
||||
key={i}
|
||||
style={{
|
||||
color: s.emphasized ? 'var(--text-0)' : 'var(--text-secondary)',
|
||||
fontSize: s.emphasized ? 16 : 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface FullProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
tier: 'free' | 'analyst' | 'desk';
|
||||
scan_count: number;
|
||||
scan_reset_date: string;
|
||||
subscription_status: string;
|
||||
subscription_end: string | null;
|
||||
founder_pricing: boolean;
|
||||
cancel_at_period_end: boolean;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { user, signOut, loading: authLoading } = useAuth();
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/profile');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
fetch('/api/user/profile', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(setProfile)
|
||||
.catch(() => setProfile(null));
|
||||
}, [user]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Cancel your subscription at the end of the current period?')) return;
|
||||
setWorking(true);
|
||||
setError('');
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ cancel_at_period_end: true }),
|
||||
});
|
||||
setWorking(false);
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setProfile((p) => (p ? { ...p, ...updated } : p));
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.error || 'Could not update — try again in a moment.');
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || !user || !profile) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading profile…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 4 }}>
|
||||
Profile
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
{profile.email}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tier card */}
|
||||
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 24, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<div>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>YOUR TIER</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier) }}>
|
||||
{profile.tier}
|
||||
{profile.founder_pricing && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--text-primary)',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
FOUNDER
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{profile.tier === 'free' && (
|
||||
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.tier !== 'free' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
||||
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.tier === 'free' && (
|
||||
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Founder pricing promo for free users */}
|
||||
{profile.tier === 'free' && (
|
||||
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
FOUNDER ACCESS
|
||||
</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>
|
||||
Lock $14.99/mo for life before it's gone.
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
First 100 users keep founder pricing even after the regular price jumps to $24.99.
|
||||
</p>
|
||||
<a href="/api/checkout?tier=analyst" className="btn-primary">
|
||||
Lock founder price →
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Subscription actions */}
|
||||
{profile.tier !== 'free' && !profile.cancel_at_period_end && (
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
Your access continues through the end of the current billing period. No refunds for partial months.
|
||||
</p>
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<button onClick={handleCancel} disabled={working} className="btn-ghost" style={{ color: 'var(--grade-d)' }}>
|
||||
{working ? 'Working…' : 'Cancel at period end'}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{profile.cancel_at_period_end && (
|
||||
<section className="surface" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-c)' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--grade-c)' }}>
|
||||
Cancellation scheduled. Access ends{' '}
|
||||
{profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Sign out */}
|
||||
<section style={{ marginTop: 32, textAlign: 'center' }}>
|
||||
<button onClick={() => signOut().then(() => router.replace('/'))} className="btn-ghost" style={{ padding: '12px 24px' }}>
|
||||
Sign out
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'warn' }) {
|
||||
const color = tone === 'good' ? 'var(--grade-a)' : tone === 'warn' ? 'var(--grade-c)' : 'var(--text-primary)';
|
||||
return (
|
||||
<div style={{ padding: '12px 14px', background: 'var(--bg-elevated)', borderRadius: 10 }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color, marginTop: 4, textTransform: 'capitalize' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tierColor(tier: string): string {
|
||||
if (tier === 'desk') return 'var(--grade-a)';
|
||||
if (tier === 'analyst') return 'var(--grade-b)';
|
||||
return 'var(--text-primary)';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Responsible Gambling',
|
||||
description: 'VYNDR provides analytics, not bets. If gambling stops being fun, stop.',
|
||||
};
|
||||
|
||||
export default function ResponsibleGamblingPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 680, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>
|
||||
If gambling stops being fun, stop.
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
RESPONSIBLE GAMBLING
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 16, lineHeight: 1.7 }}>
|
||||
<p style={{ marginBottom: 16 }}>
|
||||
VYNDR provides sports analysis for informational purposes only. We do not take bets, facilitate wagers, or encourage gambling. We exist to help bettors make more informed decisions — including the decision to walk away.
|
||||
</p>
|
||||
<p style={{ marginBottom: 16 }}>
|
||||
Sports betting is entertainment. It should never feel like a job, a financial plan, or an escape. If you find yourself chasing losses, hiding bets from people you love, or betting more than you can afford to lose, those are warning signs that gambling has stopped being fun.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="surface diagonal-cut" style={{ padding: 24, marginTop: 32, borderColor: 'var(--grade-a)' }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Get help</h2>
|
||||
<ul style={{ display: 'grid', gap: 12, fontSize: 15 }}>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
|
||||
<br />
|
||||
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
1-800-522-4700
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>24/7, free, confidential.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
|
||||
ncpgambling.org
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Resources, support groups, self-assessment tools.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.gamblersanonymous.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)' }}>
|
||||
gamblersanonymous.org
|
||||
</a>
|
||||
<br />
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Local meetings nationwide.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 32 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Self-exclusion</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
|
||||
Most US sportsbooks support a "self-exclusion" registry. You can voluntarily ban yourself from accessing sportsbook accounts for a period of months or years. Your state's gaming control board can help — search "[your state] gaming control board self-exclusion" to start.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 32 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>Our position</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: 15 }}>
|
||||
We built VYNDR because we believe sports bettors deserve better tools than the books give them. We do not believe gambling makes anyone's life better when it stops being fun. If you need to walk away from sports betting entirely, that's the right call. Cancel your subscription, delete the app, and reach out to one of the resources above.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+566
-249
@@ -1,292 +1,609 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import GradeCard from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import {
|
||||
trackScanCompleted,
|
||||
trackScanLimitHit,
|
||||
trackUpgradeClicked,
|
||||
} from '@/lib/analytics';
|
||||
|
||||
interface Leg {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number | string;
|
||||
direction: string;
|
||||
book: string;
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
status: 'scheduled' | 'live' | 'final';
|
||||
prop_count?: number;
|
||||
}
|
||||
|
||||
interface LegResult {
|
||||
index: number;
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: string;
|
||||
interface Player {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
interface ScanResponse {
|
||||
grade: string;
|
||||
confidence: number;
|
||||
edge_pct: number;
|
||||
kill_conditions: { code: string; reason: string }[];
|
||||
reasoning_summary: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
scan_id: string;
|
||||
parlay_grade: string;
|
||||
parlay_confidence: number;
|
||||
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
|
||||
legs: LegResult[];
|
||||
scan_count: number;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: Record<string, string>;
|
||||
alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number }[];
|
||||
kill_conditions?: { code: string; reason: string }[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
scans_remaining: number | null;
|
||||
upgrade_pitch: {
|
||||
hook: string;
|
||||
insight: string;
|
||||
cta: string;
|
||||
tier_recommended: string;
|
||||
founder_price: string;
|
||||
} | null;
|
||||
tier: 'free' | 'analyst' | 'desk';
|
||||
error?: string;
|
||||
upgrade?: { tier: string; price: number };
|
||||
}
|
||||
|
||||
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
|
||||
const BOOKS = ['draftkings', 'fanduel', 'betmgm'];
|
||||
const NBA_STATS = [
|
||||
{ id: 'points', label: 'Points' },
|
||||
{ id: 'rebounds', label: 'Rebounds' },
|
||||
{ id: 'assists', label: 'Assists' },
|
||||
{ id: 'threes', label: '3-Pointers' },
|
||||
{ id: 'steals', label: 'Steals' },
|
||||
{ id: 'blocks', label: 'Blocks' },
|
||||
{ id: 'pra', label: 'P+R+A' },
|
||||
{ id: 'turnovers', label: 'Turnovers' },
|
||||
];
|
||||
|
||||
const emptyLeg: Leg = { player: '', stat_type: 'points', line: '', direction: 'over', book: 'draftkings' };
|
||||
const MLB_STATS = [
|
||||
{ id: 'strikeouts', label: 'Strikeouts (P)' },
|
||||
{ id: 'hits_allowed', label: 'Hits Allowed (P)' },
|
||||
{ id: 'earned_runs', label: 'Earned Runs (P)' },
|
||||
{ id: 'innings_pitched', label: 'Innings Pitched (P)' },
|
||||
{ id: 'hits', label: 'Hits' },
|
||||
{ id: 'total_bases', label: 'Total Bases' },
|
||||
{ id: 'rbi', label: 'RBI' },
|
||||
{ id: 'runs', label: 'Runs' },
|
||||
{ id: 'home_runs', label: 'Home Runs' },
|
||||
];
|
||||
|
||||
const WNBA_STATS = NBA_STATS;
|
||||
|
||||
const SPORT_STATS: Record<Sport, { id: string; label: string }[]> = {
|
||||
NBA: NBA_STATS,
|
||||
MLB: MLB_STATS,
|
||||
WNBA: WNBA_STATS,
|
||||
};
|
||||
|
||||
const SPORT_ACCENT: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
const [legs, setLegs] = useState<Leg[]>([{ ...emptyLeg }]);
|
||||
const [results, setResults] = useState<ScanResult | null>(null);
|
||||
const router = useRouter();
|
||||
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
|
||||
const { addLeg, legCount, open } = useParlay();
|
||||
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [gameId, setGameId] = useState<string>('');
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
|
||||
const [stat, setStat] = useState<string>('points');
|
||||
const [line, setLine] = useState<string>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<ScanResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<string[]>([]);
|
||||
const [activeInput, setActiveInput] = useState(-1);
|
||||
|
||||
const updateLeg = (index: number, field: keyof Leg, value: string | number) => {
|
||||
setLegs((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
|
||||
};
|
||||
// Auth gate — push anonymous users to signup
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/signup?next=/scan');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const addLeg = () => {
|
||||
if (legs.length < 12) setLegs([...legs, { ...emptyLeg }]);
|
||||
};
|
||||
// Reset stat selection when sport changes
|
||||
useEffect(() => {
|
||||
const list = SPORT_STATS[sport];
|
||||
if (!list.some((s) => s.id === stat)) setStat(list[0].id);
|
||||
}, [sport, stat]);
|
||||
|
||||
const removeLeg = (index: number) => {
|
||||
if (legs.length > 1) setLegs(legs.filter((_, i) => i !== index));
|
||||
};
|
||||
// Load tonight's slate
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setGames(null);
|
||||
setGameId('');
|
||||
fetch(`/api/games/tonight?sport=${sport}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { games: Game[] }) => {
|
||||
if (!cancelled) setGames(Array.isArray(data?.games) ? data.games : []);
|
||||
})
|
||||
.catch(() => !cancelled && setGames([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
const searchPlayer = useCallback(async (name: string, index: number) => {
|
||||
if (name.length < 2) { setPlayerSuggestions([]); return; }
|
||||
setActiveInput(index);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000'}/players/search?name=${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
setPlayerSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
|
||||
} catch { setPlayerSuggestions([]); }
|
||||
}, []);
|
||||
// Debounced player search — narrow to selected game when set
|
||||
const searchPlayers = useCallback(
|
||||
async (query: string) => {
|
||||
if (query.trim().length < 2) {
|
||||
setPlayerSuggestions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({ sport, q: query });
|
||||
if (gameId) params.set('game_id', gameId);
|
||||
const res = await fetch(`/api/players/search?${params}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { players: Player[] };
|
||||
setPlayerSuggestions((data.players || []).slice(0, 8));
|
||||
} catch {
|
||||
setPlayerSuggestions([]);
|
||||
}
|
||||
},
|
||||
[sport, gameId],
|
||||
);
|
||||
|
||||
const scan = async () => {
|
||||
const validLegs = legs.filter((l) => l.player && l.line);
|
||||
if (validLegs.length < 2) { setError('Add at least 2 legs'); return; }
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => void searchPlayers(playerQuery), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [playerQuery, searchPlayers]);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => selectedPlayer && stat && line !== '' && !scanning && canScan,
|
||||
[selectedPlayer, stat, line, scanning, canScan],
|
||||
);
|
||||
|
||||
const runScan = async () => {
|
||||
if (!canSubmit) {
|
||||
if (!canScan) {
|
||||
trackScanLimitHit({ current_scan_count: 5, tier });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setResults(null);
|
||||
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/scan/parlay`, {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(typeof window !== 'undefined' && localStorage.getItem('sb-token')
|
||||
? { Authorization: `Bearer ${localStorage.getItem('sb-token')}` }
|
||||
: {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: validLegs.map((l) => ({ ...l, line: Number(l.line) })) }),
|
||||
body: JSON.stringify({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
direction,
|
||||
book: 'draftkings',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Scan failed');
|
||||
setResults(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
const data = (await res.json()) as ScanResponse;
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'The engine hit a wall. Try that read again.');
|
||||
if (res.status === 402) trackScanLimitHit({ current_scan_count: 5, tier });
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
bumpScanCount();
|
||||
trackScanCompleted({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
grade: data.grade,
|
||||
tier,
|
||||
});
|
||||
} catch {
|
||||
setError('The engine hit a wall. Try that read again.');
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setResult(null);
|
||||
setError('');
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setLine('');
|
||||
};
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-8 px-4 max-w-3xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Scan Parlay</h1>
|
||||
{results && results.scans_remaining != null && (
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
<span className="font-mono">{results.scan_count}</span> of 5 scans used
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section
|
||||
className="diagonal-cut animate-fade-up"
|
||||
style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px', position: 'relative' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
Grade a prop.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Pick a sport, find the player, set the line. We grade it in seconds.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Leg Builder */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{legs.map((leg, i) => (
|
||||
<div key={i} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">Leg {i + 1}</span>
|
||||
{legs.length > 1 && (
|
||||
<button onClick={() => removeLeg(i)} className="text-xs text-[var(--grade-d)] hover:text-white">Remove</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="col-span-2 relative">
|
||||
<input
|
||||
placeholder="Player name"
|
||||
value={leg.player}
|
||||
onChange={(e) => {
|
||||
updateLeg(i, 'player', e.target.value);
|
||||
searchPlayer(e.target.value, i);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setPlayerSuggestions([]), 200)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
{activeInput === i && playerSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
{playerSuggestions.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onMouseDown={() => { updateLeg(i, 'player', name); setPlayerSuggestions([]); }}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--border)] transition"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={leg.stat_type}
|
||||
onChange={(e) => updateLeg(i, 'stat_type', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="Line"
|
||||
value={leg.line}
|
||||
onChange={(e) => updateLeg(i, 'line', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
<select
|
||||
value={leg.direction}
|
||||
onChange={(e) => updateLeg(i, 'direction', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
<option value="over">Over</option>
|
||||
<option value="under">Under</option>
|
||||
</select>
|
||||
<select
|
||||
value={leg.book}
|
||||
onChange={(e) => updateLeg(i, 'book', e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
|
||||
>
|
||||
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-8">
|
||||
{legs.length < 12 && (
|
||||
<button onClick={addLeg} className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition">
|
||||
+ Add Leg
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={scanning || legs.filter((l) => l.player && l.line).length < 2}
|
||||
className="flex-1 py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-40"
|
||||
{/* Scan counter */}
|
||||
{tier === 'free' && scansRemaining != null && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{scanning ? 'Scanning...' : 'Scan Parlay'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-4 mb-6 rounded-xl bg-[var(--grade-d)]/10 border border-[var(--grade-d)] text-[var(--grade-d)] text-sm">{error}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Grade */}
|
||||
<div className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<p className="text-sm text-[var(--text-muted)] mb-3">Parlay Grade</p>
|
||||
<div className="flex justify-center mb-3">
|
||||
<GradeCard grade={results.parlay_grade} confidence={results.parlay_confidence} />
|
||||
</div>
|
||||
{results.correlation_flags.length > 0 && (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{results.correlation_flags.length} correlation flag{results.correlation_flags.length > 1 ? 's' : ''} detected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Individual Legs */}
|
||||
{results.legs.map((leg) => (
|
||||
<div key={leg.index} className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{leg.player}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{leg.direction.charAt(0).toUpperCase() + leg.direction.slice(1)} {leg.line} {leg.stat_type}
|
||||
</p>
|
||||
</div>
|
||||
<GradeCard grade={leg.grade} confidence={leg.confidence} />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-2">{leg.reasoning_summary}</p>
|
||||
{leg.edge_pct !== 0 && (
|
||||
<span className={`text-xs font-mono ${leg.edge_pct > 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
|
||||
Edge: {leg.edge_pct > 0 ? '+' : ''}{leg.edge_pct}%
|
||||
</span>
|
||||
)}
|
||||
{leg.kill_conditions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{leg.kill_conditions.map((k) => (
|
||||
<span key={k.code} className="px-2 py-0.5 text-xs bg-[var(--grade-d)]/10 text-[var(--grade-d)] rounded">
|
||||
{k.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Correlations */}
|
||||
{results.correlation_flags.length > 0 && (
|
||||
<div className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<h3 className="font-semibold mb-3">Correlations</h3>
|
||||
{results.correlation_flags.map((flag, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm mb-2">
|
||||
<span className={flag.impact === 'major_negative' ? 'text-[var(--grade-d)]' : flag.impact === 'positive' ? 'text-[var(--grade-a)]' : 'text-[var(--grade-c)]'}>
|
||||
{flag.impact === 'major_negative' ? '!!' : flag.impact === 'positive' ? '+' : '!'}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">{flag.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Pitch */}
|
||||
{results.upgrade_pitch && (
|
||||
<div className="p-6 rounded-2xl bg-[var(--accent)]/10 border border-[var(--accent)]">
|
||||
<p className="font-semibold mb-2">{results.upgrade_pitch.hook}</p>
|
||||
<p className="text-sm text-[var(--text-muted)] mb-4">{results.upgrade_pitch.insight}</p>
|
||||
<a href="#pricing" className="inline-block px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
|
||||
{results.upgrade_pitch.cta}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setResults(null); setLegs([{ ...emptyLeg }]); }}
|
||||
className="flex-1 py-3 border border-[var(--border)] rounded-xl text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
New Scan
|
||||
</button>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', letterSpacing: '0.05em' }}>
|
||||
{scansRemaining} OF 5 FREE READS REMAINING THIS MONTH
|
||||
</span>
|
||||
<div style={{ flex: 1, maxWidth: 160, height: 4, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${(scansRemaining / 5) * 100}%`,
|
||||
height: '100%',
|
||||
background: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--grade-a)',
|
||||
transition: 'width 200ms ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport tabs */}
|
||||
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)' }}>
|
||||
{(Object.keys(SPORT_STATS) as Sport[]).map((s) => {
|
||||
const active = s === sport;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setSport(s)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${active ? SPORT_ACCENT[s] : 'transparent'}`,
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
transition: 'color 200ms ease',
|
||||
marginBottom: -1,
|
||||
boxShadow: active ? `0 4px 16px ${SPORT_ACCENT[s]}26` : 'none',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Game selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="mono" style={labelStyle}>Tonight's slate</label>
|
||||
{games === null ? (
|
||||
<div style={shimmerStyle} />
|
||||
) : games.length === 0 ? (
|
||||
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
No games posted yet. Books usually open player props 2–3 hours before tip.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={gameId}
|
||||
onChange={(e) => setGameId(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Game"
|
||||
>
|
||||
<option value="">All games</option>
|
||||
{games.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.away} @ {g.home} · {formatTime(g.start_time)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Player search */}
|
||||
<div style={{ marginBottom: 16, position: 'relative' }}>
|
||||
<label className="mono" style={labelStyle}>Player</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder={`Search ${sport} players`}
|
||||
value={playerQuery}
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
||||
<div
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: 4,
|
||||
zIndex: 20,
|
||||
padding: 4,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{playerSuggestions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedPlayer(p.full_name);
|
||||
setPlayerQuery(p.full_name);
|
||||
setPlayerSuggestions([]);
|
||||
}}
|
||||
style={suggestionStyle}
|
||||
>
|
||||
<span>{p.full_name}</span>
|
||||
{p.team && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
{p.team}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat + line + direction */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr 0.8fr', gap: 12, marginBottom: 24 }}>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Stat</label>
|
||||
<select className="input-field" value={stat} onChange={(e) => setStat(e.target.value)}>
|
||||
{SPORT_STATS[sport].map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Line</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
inputMode="decimal"
|
||||
className="input-field"
|
||||
placeholder="0.0"
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Side</label>
|
||||
<div style={{ display: 'flex', borderRadius: 12, overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
{(['over', 'under'] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDirection(d)}
|
||||
aria-pressed={direction === d}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 0',
|
||||
background: direction === d ? 'var(--accent)' : 'transparent',
|
||||
color: direction === d ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
textTransform: 'capitalize',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade button OR upgrade trigger */}
|
||||
{!canScan ? (
|
||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 32, textAlign: 'center', maxWidth: 440, margin: '0 auto' }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)', marginBottom: 12 }}>SIGNAL EXHAUSTED</p>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
||||
You've used your 5 free reads this month.
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 20 }}>
|
||||
Unlock unlimited reads — plus kill conditions, alt lines, and the full intelligence layer.
|
||||
</p>
|
||||
<p className="num" style={{
|
||||
fontSize: 32, color: 'var(--grade-a)', marginBottom: 4,
|
||||
textShadow: '0 0 14px rgba(0, 212, 160, 0.7)',
|
||||
}}>
|
||||
$14.99<span style={{ fontSize: 14, color: 'var(--text-1)' }}>/mo</span>
|
||||
</p>
|
||||
<p style={{ color: 'var(--grade-c)', fontSize: 13, fontWeight: 600, marginBottom: 20 }}>
|
||||
Locked for life. This rate disappears June 15.
|
||||
</p>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', padding: 14 }}
|
||||
onClick={() => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'scan_limit' });
|
||||
router.push('/api/checkout?tier=analyst');
|
||||
}}
|
||||
>
|
||||
Upgrade Now
|
||||
</button>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 12 }}>
|
||||
Or come back next month for 5 more free reads.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={runScan}
|
||||
disabled={!canSubmit}
|
||||
className={scanning ? 'shimmer-loading' : 'btn-primary'}
|
||||
style={{ width: '100%', padding: 16, fontSize: 15, color: 'var(--text-primary)', border: 'none', borderRadius: 12, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed', opacity: canSubmit ? 1 : 0.4 }}
|
||||
>
|
||||
{scanning ? 'Running the model…' : 'Read It'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,107,107,0.10)',
|
||||
border: '1px solid rgba(255,107,107,0.30)',
|
||||
color: 'var(--grade-d)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grade card output */}
|
||||
{result && (
|
||||
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
|
||||
<GradeCard
|
||||
sport={sport}
|
||||
player={selectedPlayer}
|
||||
stat={stat}
|
||||
line={Number(line)}
|
||||
direction={direction}
|
||||
grade={result.grade}
|
||||
projection={result.projection}
|
||||
confidence={result.confidence}
|
||||
sample_size={result.sample_size}
|
||||
factors={result.factors}
|
||||
alt_lines={result.alt_lines}
|
||||
kill_conditions={result.kill_conditions}
|
||||
reasoning={result.reasoning}
|
||||
historical_hit_rate={result.historical_hit_rate}
|
||||
tier={tier}
|
||||
onUpgradeClick={(target, from) => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from });
|
||||
router.push(`/api/checkout?tier=${target}`);
|
||||
}}
|
||||
onAddToParlay={() => {
|
||||
addLeg({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
stat,
|
||||
line: Number(line),
|
||||
direction,
|
||||
grade: result.grade,
|
||||
confidence: result.confidence ?? 50,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button onClick={reset} className="btn-ghost" style={{ flex: 1 }}>
|
||||
Read another prop
|
||||
</button>
|
||||
<a href="/dashboard" className="btn-ghost" style={{ flex: 1 }}>
|
||||
Back to slate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Helpful sticky parlay indicator */}
|
||||
{legCount > 0 && (
|
||||
<button
|
||||
onClick={open}
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 88,
|
||||
right: 16,
|
||||
zIndex: 30,
|
||||
padding: '10px 16px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent)',
|
||||
border: '1px solid var(--accent-light)',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
boxShadow: '0 8px 24px var(--accent-glow)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Parlay · {legCount} leg{legCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const shimmerStyle: React.CSSProperties = {
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background:
|
||||
'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s linear infinite',
|
||||
};
|
||||
|
||||
const suggestionStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import ExplainModeToggle from '@/components/ExplainModeToggle';
|
||||
|
||||
// MFA settings page. Gated to paid tiers (analyst | desk) — free users get
|
||||
// redirected to /pricing. We don't *force* MFA, only strongly encourage it,
|
||||
// because we don't want to lock paying users out of an account they own.
|
||||
|
||||
type EnrollState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'enrolling'; factorId: string; qr: string; secret: string }
|
||||
| { status: 'enrolled' }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const [hasMFA, setHasMFA] = useState<boolean | null>(null);
|
||||
const [enrollState, setEnrollState] = useState<EnrollState>({ status: 'idle' });
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const refreshFactors = useCallback(async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
if (error) return;
|
||||
const verified = (data?.totp ?? []).some((f) => f.status === 'verified');
|
||||
setHasMFA(verified);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
router.replace('/login?next=/settings/security');
|
||||
return;
|
||||
}
|
||||
if (tier === 'free') {
|
||||
router.replace('/upgrade/desk');
|
||||
return;
|
||||
}
|
||||
void refreshFactors();
|
||||
}, [authLoading, user, tier, router, refreshFactors]);
|
||||
|
||||
const startEnroll = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp', friendlyName: 'VYNDR' });
|
||||
if (error) {
|
||||
setEnrollState({ status: 'error', message: error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({
|
||||
status: 'enrolling',
|
||||
factorId: data.id,
|
||||
qr: data.totp.qr_code,
|
||||
secret: data.totp.secret,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (enrollState.status !== 'enrolling') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const challenge = await supabase.auth.mfa.challenge({ factorId: enrollState.factorId });
|
||||
if (challenge.error) {
|
||||
setEnrollState({ status: 'error', message: challenge.error.message });
|
||||
return;
|
||||
}
|
||||
const verifyRes = await supabase.auth.mfa.verify({
|
||||
factorId: enrollState.factorId,
|
||||
challengeId: challenge.data.id,
|
||||
code,
|
||||
});
|
||||
if (verifyRes.error) {
|
||||
setEnrollState({ status: 'error', message: verifyRes.error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({ status: 'enrolled' });
|
||||
setHasMFA(true);
|
||||
setCode('');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
if (!window.confirm('Disable two-factor authentication? Your account will be less secure.')) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
|
||||
if (!factor) return;
|
||||
const res = await supabase.auth.mfa.unenroll({ factorId: factor.id });
|
||||
if (res.error) {
|
||||
setEnrollState({ status: 'error', message: res.error.message });
|
||||
return;
|
||||
}
|
||||
setHasMFA(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || hasMFA === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12">
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Account security
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Two-factor authentication adds a one-time code on top of your password.
|
||||
</p>
|
||||
|
||||
<section
|
||||
className="mt-8 rounded-lg border p-5"
|
||||
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Authenticator app
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasMFA ? 'Enabled — codes from your authenticator are required at sign-in.' : 'Not enabled.'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
style={{
|
||||
background: hasMFA ? 'var(--grade-a)' : 'var(--bg-elevated)',
|
||||
color: hasMFA ? 'var(--bg-0)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{hasMFA ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hasMFA && enrollState.status === 'idle' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEnroll}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolling' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Scan this code with Google Authenticator, Authy, or 1Password.
|
||||
</p>
|
||||
{/* QR is a data: URI emitted by Supabase — safe to embed directly. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={enrollState.qr} alt="MFA QR code" width={180} height={180} />
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Can't scan? Enter this code manually: <code>{enrollState.secret}</code>
|
||||
</p>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="6-digit code"
|
||||
className="w-32 rounded border px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verify}
|
||||
disabled={code.length !== 6 || submitting}
|
||||
className="ml-2 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolled' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-a)' }}>
|
||||
MFA enabled. You'll be asked for a code the next time you sign in.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'error' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-d)' }}>
|
||||
{enrollState.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMFA && enrollState.status !== 'enrolling' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disable}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded border px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Disable MFA
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<ExplainModeToggle variant="full" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+154
-29
@@ -1,63 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackSignup } from '@/lib/analytics';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
function SignupInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') || '/dashboard';
|
||||
const { signUp, signInWithGoogle } = useAuth();
|
||||
|
||||
export default function SignupPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [ageOk, setAgeOk] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
// TODO: Integrate with Supabase Auth
|
||||
// const { error } = await supabase.auth.signUp({ email, password });
|
||||
setLoading(false);
|
||||
setError('Auth integration pending. Backend is ready.');
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.');
|
||||
if (password !== confirm) return setError('Passwords do not match.');
|
||||
if (!ageOk) return setError('You must confirm you are 21 or older.');
|
||||
|
||||
setBusy(true);
|
||||
const { error: err } = await signUp(email, password, ageOk);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
trackSignup({ method: 'password' });
|
||||
setDone(true);
|
||||
// Supabase confirms via email by default. If session is immediate, redirect.
|
||||
setTimeout(() => router.replace(next), 1500);
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy(true);
|
||||
await signInWithGoogle();
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut" style={{ maxWidth: 420, padding: 32, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 48, color: 'var(--grade-a)', marginBottom: 16 }}>✓</div>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 12 }}>You're in.</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Check your email for a confirmation link, then come back and grade something.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-center mb-2">Create Account</h1>
|
||||
<p className="text-center text-[var(--text-muted)] text-sm mb-8">5 free scans. No credit card required.</p>
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Get started — free</h1>
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
|
||||
5 free reads every month. Your first read is fully unlocked. No credit card.
|
||||
</p>
|
||||
|
||||
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div style={dividerStyle}>
|
||||
<span style={dividerLine} />
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>OR</span>
|
||||
<span style={dividerLine} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignup} style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Email</label>
|
||||
<label className="mono" style={labelStyle}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--text-muted)] mb-1">Password</label>
|
||||
<label className="mono" style={labelStyle}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--card)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
|
||||
autoComplete="new-password"
|
||||
className="input-field"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-[var(--grade-d)] text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign Up — Free'}
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="input-field"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ageOk}
|
||||
onChange={(e) => setAgeOk(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>I confirm I am 21 years of age or older.</span>
|
||||
</label>
|
||||
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
||||
{busy ? 'Creating account…' : 'Get started — free'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-[var(--text-muted)] mt-6">
|
||||
Already have an account? <a href="/login" className="text-[var(--accent)] hover:underline">Log in</a>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.6 }}>
|
||||
By signing up you agree to our{' '}
|
||||
<a href="/terms" style={{ color: 'var(--text-secondary)' }}>Terms</a> and{' '}
|
||||
<a href="/privacy" style={{ color: 'var(--text-secondary)' }}>Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-secondary)', marginTop: 12 }}>
|
||||
Already have an account?{' '}
|
||||
<a href={`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`} style={{ color: 'var(--grade-a)' }}>
|
||||
Log in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SignupInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
margin: '16px 0',
|
||||
};
|
||||
|
||||
const dividerLine: React.CSSProperties = {
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Terms of Service',
|
||||
description: 'VYNDR terms of service. Plain English. No tricks.',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
|
||||
{
|
||||
title: 'What this is',
|
||||
body: [
|
||||
'VYNDR provides sports analysis for informational purposes only. VYNDR does not facilitate, process, or encourage any wagers. VYNDR is not a sportsbook.',
|
||||
'VYNDR Intelligence LLC operates this product. We grade player props using statistical models, historical data, and contextual intelligence.',
|
||||
],
|
||||
emphasized: true,
|
||||
},
|
||||
{
|
||||
title: 'Who can use this',
|
||||
body: [
|
||||
'You must be 21 years of age or older to create an account. You must use VYNDR only where sports betting and the use of sports analytics tools is legal in your jurisdiction. You are responsible for following your local laws.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'No guaranteed outcomes',
|
||||
body: [
|
||||
'A grade is a probabilistic assessment. An "A" grade is not a guarantee, prediction, or promise. The model is wrong all the time — we publish the ledger so you can see how often. Past performance does not guarantee future results. Do not bet what you cannot afford to lose.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Subscription terms',
|
||||
body: [
|
||||
'Paid tiers (Analyst, Desk) are billed monthly through NexaPay. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months.',
|
||||
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
|
||||
'We may change regular pricing with 30 days notice. Founder pricing is locked.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Acceptable use',
|
||||
body: [
|
||||
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Intellectual property',
|
||||
body: [
|
||||
'The grading engine, models, calibration data, factor weights, and surrounding analysis are proprietary to VYNDR Intelligence LLC. You are licensed to view grades for personal use. You are not licensed to redistribute, repackage, or sell VYNDR output.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Limitation of liability',
|
||||
body: [
|
||||
'VYNDR is not responsible for any financial losses you incur from sports betting decisions you make using our analytics. The service is provided "as is" with no warranty. Maximum liability is limited to what you paid us in the prior 12 months.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Governing law',
|
||||
body: [
|
||||
'These terms are governed by the laws of the State of Michigan. Any disputes will be resolved through binding arbitration in Wayne County, Michigan.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Responsible gambling',
|
||||
body: [
|
||||
'Sports betting can become a problem. If gambling stops being fun, stop. The National Council on Problem Gambling helpline is available 24/7, free and confidential: 1-800-522-4700. More resources at ncpgambling.org. See our Responsible Gambling page for self-exclusion guidance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
body: [
|
||||
'VYNDR Intelligence LLC. Questions about these terms: legal@vyndr.app',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalLayout
|
||||
title="Terms of Service"
|
||||
effective="Effective: May 2026"
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLayout({ title, effective, sections }: { title: string; effective: string; sections: { title: string; body: string[]; emphasized?: boolean }[] }) {
|
||||
return (
|
||||
<section style={{ maxWidth: 720, margin: '0 auto', padding: '48px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 8 }}>{title}</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
|
||||
{effective.toUpperCase()}
|
||||
</p>
|
||||
</header>
|
||||
{sections.map((s) => (
|
||||
<section
|
||||
key={s.title}
|
||||
style={{
|
||||
marginBottom: 32,
|
||||
...(s.emphasized
|
||||
? {
|
||||
borderLeft: '3px solid var(--grade-a)',
|
||||
paddingLeft: 16,
|
||||
background: 'rgba(0, 212, 160, 0.04)',
|
||||
borderRadius: 4,
|
||||
padding: '12px 16px',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: s.emphasized ? 20 : 18, fontWeight: 700, marginBottom: 12 }}>{s.title}</h2>
|
||||
{s.body.map((p, i) => (
|
||||
<p
|
||||
key={i}
|
||||
style={{
|
||||
color: s.emphasized ? 'var(--text-0)' : 'var(--text-secondary)',
|
||||
fontSize: s.emphasized ? 16 : 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+227
-214
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type Period = 'weekly' | 'monthly' | 'all_time';
|
||||
|
||||
@@ -18,271 +20,282 @@ interface Bet {
|
||||
potential_payout: number;
|
||||
book: string;
|
||||
bet_type: string;
|
||||
status: string;
|
||||
slip_data: { legs: any[]; total_odds?: number; scan_session_id?: string };
|
||||
submission_method: string;
|
||||
status: 'pending' | 'won' | 'lost' | 'push' | 'void';
|
||||
slip_data: { legs: { player?: string; stat_type?: string; line?: number; direction?: string }[]; total_odds?: number };
|
||||
placed_at: string;
|
||||
settled_at: string | null;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
||||
return token
|
||||
? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
: { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export default function TrackerPage() {
|
||||
const router = useRouter();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [period, setPeriod] = useState<Period>('monthly');
|
||||
const [performance, setPerformance] = useState<Record<Period, PerfStats | null>>({ weekly: null, monthly: null, all_time: null });
|
||||
const [performance, setPerformance] = useState<Record<Period, PerfStats | null>>({
|
||||
weekly: null, monthly: null, all_time: null,
|
||||
});
|
||||
const [bets, setBets] = useState<Bet[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settlingId, setSettlingId] = useState<string | null>(null);
|
||||
const [settleStatus, setSettleStatus] = useState('won');
|
||||
const [showQuickSlip, setShowQuickSlip] = useState(false);
|
||||
|
||||
// Quick slip state
|
||||
const [showQuickSlip, setShowQuickSlip] = useState(false);
|
||||
const [slipPlayer, setSlipPlayer] = useState('');
|
||||
const [slipStat, setSlipStat] = useState('points');
|
||||
const [slipLine, setSlipLine] = useState('');
|
||||
const [slipDirection, setSlipDirection] = useState('over');
|
||||
const [slipOdds, setSlipOdds] = useState('-110');
|
||||
const [slipAmount, setSlipAmount] = useState('');
|
||||
const [slipBook, setSlipBook] = useState('draftkings');
|
||||
const [slip, setSlip] = useState({
|
||||
player: '', stat: 'points', line: '', direction: 'over',
|
||||
odds: '-110', amount: '', book: 'draftkings',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace('/login?next=/tracker');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '20', offset: '0' });
|
||||
const params = new URLSearchParams({ limit: '30' });
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const [perfRes, betsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/bets/performance`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API_BASE}/api/bets?${params}`, { headers: getAuthHeaders() }),
|
||||
]);
|
||||
|
||||
if (perfRes.ok) {
|
||||
const perfData = await perfRes.json();
|
||||
setPerformance(perfData);
|
||||
}
|
||||
if (perfRes.ok) setPerformance(await perfRes.json());
|
||||
if (betsRes.ok) {
|
||||
const betsData = await betsRes.json();
|
||||
setBets(betsData.bets || []);
|
||||
setTotal(betsData.total || 0);
|
||||
const data = await betsRes.json();
|
||||
setBets(data.bets || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tracker data');
|
||||
} catch {
|
||||
/* network failure — keep last successful state */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleSettle = async (betId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/bets/${betId}/settle`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status: settleStatus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettlingId(null);
|
||||
fetchData();
|
||||
}
|
||||
} catch (e) { console.error('Settle failed'); }
|
||||
};
|
||||
useEffect(() => { if (user) fetchData(); }, [fetchData, user]);
|
||||
|
||||
const handleQuickSlip = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/bets/quickslip`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
legs: [{ player: slipPlayer, stat_type: slipStat, line: Number(slipLine), direction: slipDirection, odds: Number(slipOdds) }],
|
||||
amount: Number(slipAmount),
|
||||
book: slipBook,
|
||||
bet_type: 'straight',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowQuickSlip(false);
|
||||
setSlipPlayer(''); setSlipLine(''); setSlipAmount('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (e) { console.error('Quick slip failed'); }
|
||||
const res = await fetch(`${API_BASE}/api/bets/quickslip`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
legs: [{
|
||||
player: slip.player,
|
||||
stat_type: slip.stat,
|
||||
line: Number(slip.line),
|
||||
direction: slip.direction,
|
||||
odds: Number(slip.odds),
|
||||
}],
|
||||
amount: Number(slip.amount),
|
||||
book: slip.book,
|
||||
bet_type: 'straight',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowQuickSlip(false);
|
||||
setSlip({ ...slip, player: '', line: '', amount: '' });
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const settleBet = async (id: string, status: 'won' | 'lost' | 'push' | 'void') => {
|
||||
await fetch(`${API_BASE}/api/bets/${id}/settle`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const currentPerf = performance[period];
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading tracker…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-8 px-4 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Bet Tracker</h1>
|
||||
<section style={{ maxWidth: 900, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 4 }}>
|
||||
Bet tracker
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Log every bet. See what worked. The honest mirror.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition ${period === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)] hover:text-white'}`}
|
||||
>
|
||||
{p === 'all_time' ? 'All Time' : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className={`text-2xl font-mono font-bold ${(currentPerf?.roi ?? 0) >= 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
|
||||
{currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">ROI</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className="text-2xl font-mono font-bold">
|
||||
{currentPerf ? `${currentPerf.win_rate}%` : '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Win Rate</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
|
||||
<div className="text-2xl font-mono font-bold">
|
||||
{currentPerf?.sample_size ?? '--'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">Bets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Submit */}
|
||||
<div className="flex gap-3 mb-8">
|
||||
<button
|
||||
onClick={() => setShowQuickSlip(!showQuickSlip)}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition"
|
||||
>
|
||||
Quick Slip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showQuickSlip && (
|
||||
<form onSubmit={handleQuickSlip} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] mb-8 space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<input placeholder="Player" value={slipPlayer} onChange={(e) => setSlipPlayer(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<select value={slipStat} onChange={(e) => setSlipStat(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
{['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="number" step="0.5" placeholder="Line" value={slipLine} onChange={(e) => setSlipLine(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<select value={slipDirection} onChange={(e) => setSlipDirection(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
<option value="over">Over</option><option value="under">Under</option>
|
||||
</select>
|
||||
<input type="number" placeholder="Odds (e.g. -110)" value={slipOdds} onChange={(e) => setSlipOdds(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
<input type="number" step="0.01" placeholder="Amount ($)" value={slipAmount} onChange={(e) => setSlipAmount(e.target.value)} required
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select value={slipBook} onChange={(e) => setSlipBook(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
|
||||
{['draftkings', 'fanduel', 'betmgm'].map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
|
||||
Submit Bet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bet History */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
{['', 'pending', 'won', 'lost', 'push'].map((s) => (
|
||||
{/* Period switch */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
{(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-3 py-1 text-xs rounded-lg transition ${statusFilter === s ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)]'}`}
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={period === p ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ padding: '8px 14px', fontSize: 12, textTransform: 'capitalize' }}
|
||||
>
|
||||
{s || 'All'}
|
||||
{p === 'all_time' ? 'All time' : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<p className="text-[var(--text-muted)] text-sm">Loading...</p>
|
||||
) : bets.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)] text-sm">No bets yet. Submit your first bet above.</p>
|
||||
) : (
|
||||
bets.map((bet) => (
|
||||
<div key={bet.id} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{(bet.slip_data?.legs || []).map((l: any) =>
|
||||
`${l.player} ${l.direction?.[0]?.toUpperCase() || ''}${l.line} ${l.stat_type}`
|
||||
).join(' / ') || `${bet.bet_type} bet`}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
{bet.book} | ${bet.amount} | {new Date(bet.placed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded ${
|
||||
bet.status === 'won' ? 'bg-[var(--grade-a)]/10 text-[var(--grade-a)]' :
|
||||
bet.status === 'lost' ? 'bg-[var(--grade-d)]/10 text-[var(--grade-d)]' :
|
||||
'bg-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{bet.status.toUpperCase()}
|
||||
</span>
|
||||
{bet.status === 'won' && (
|
||||
<div className="text-xs text-[var(--grade-a)] mt-1 font-mono">
|
||||
+${(bet.potential_payout - bet.amount).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bet.status === 'pending' && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{settlingId === bet.id ? (
|
||||
<>
|
||||
<select value={settleStatus} onChange={(e) => setSettleStatus(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--bg)] border border-[var(--border)] text-white">
|
||||
<option value="won">Won</option>
|
||||
<option value="lost">Lost</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="void">Void</option>
|
||||
</select>
|
||||
<button onClick={() => handleSettle(bet.id)} className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90">
|
||||
Confirm
|
||||
</button>
|
||||
<button onClick={() => setSettlingId(null)} className="px-3 py-1 text-xs text-[var(--text-muted)]">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setSettlingId(bet.id)} className="text-xs text-[var(--accent)] hover:underline">
|
||||
Settle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Performance tiles */}
|
||||
<section
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{ padding: 24, marginBottom: 24, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}
|
||||
>
|
||||
<Tile
|
||||
label="ROI"
|
||||
value={currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '—'}
|
||||
tone={(currentPerf?.roi ?? 0) >= 0 ? 'good' : 'bad'}
|
||||
/>
|
||||
<Tile label="Win rate" value={currentPerf ? `${currentPerf.win_rate}%` : '—'} />
|
||||
<Tile label="Bets" value={currentPerf?.sample_size?.toString() ?? '—'} />
|
||||
<Tile label="Wagered" value={currentPerf ? `$${currentPerf.total_wagered.toFixed(0)}` : '—'} />
|
||||
<Tile
|
||||
label="Profit"
|
||||
value={currentPerf ? `${currentPerf.total_profit > 0 ? '+' : ''}$${currentPerf.total_profit.toFixed(0)}` : '—'}
|
||||
tone={(currentPerf?.total_profit ?? 0) >= 0 ? 'good' : 'bad'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Quick slip */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<button onClick={() => setShowQuickSlip((s) => !s)} className="btn-primary">
|
||||
{showQuickSlip ? 'Close quick slip' : '+ Log a bet'}
|
||||
</button>
|
||||
{showQuickSlip && (
|
||||
<form onSubmit={handleQuickSlip} className="surface" style={{ padding: 20, marginTop: 12, display: 'grid', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 8 }}>
|
||||
<input required placeholder="Player" className="input-field" value={slip.player} onChange={(e) => setSlip({ ...slip, player: e.target.value })} />
|
||||
<select className="input-field" value={slip.stat} onChange={(e) => setSlip({ ...slip, stat: e.target.value })}>
|
||||
{['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'].map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input required type="number" step="0.5" placeholder="Line" className="input-field" value={slip.line} onChange={(e) => setSlip({ ...slip, line: e.target.value })} />
|
||||
</div>
|
||||
))
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||
<select className="input-field" value={slip.direction} onChange={(e) => setSlip({ ...slip, direction: e.target.value })}>
|
||||
<option value="over">Over</option><option value="under">Under</option>
|
||||
</select>
|
||||
<input required type="number" placeholder="Odds" className="input-field" value={slip.odds} onChange={(e) => setSlip({ ...slip, odds: e.target.value })} />
|
||||
<input required type="number" step="0.01" placeholder="Stake $" className="input-field" value={slip.amount} onChange={(e) => setSlip({ ...slip, amount: e.target.value })} />
|
||||
<select className="input-field" value={slip.book} onChange={(e) => setSlip({ ...slip, book: e.target.value })}>
|
||||
{['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet'].map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" style={{ padding: 12 }}>Submit bet</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{['', 'pending', 'won', 'lost', 'push'].map((s) => (
|
||||
<button key={s} onClick={() => setStatusFilter(s)} className={statusFilter === s ? 'btn-primary' : 'btn-ghost'} style={{ padding: '6px 12px', fontSize: 11 }}>
|
||||
{s ? s.charAt(0).toUpperCase() + s.slice(1) : 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bet list */}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{loading ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 16 }}>Loading…</p>
|
||||
) : bets.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
No bets logged yet. Use the quick slip above to start the ledger.
|
||||
</p>
|
||||
) : (
|
||||
bets.map((b) => <BetRow key={b.id} bet={b} onSettle={settleBet} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bets.length > 0 && bets.length < total && (
|
||||
<button className="w-full mt-4 py-2 text-sm text-[var(--text-muted)] hover:text-white transition">
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
<p style={{ marginTop: 32, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
Future: connect DraftKings / FanDuel for auto-sync (coming soon).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' }) {
|
||||
const color = tone === 'good' ? 'var(--grade-a)' : tone === 'bad' ? 'var(--grade-d)' : 'var(--text-primary)';
|
||||
return (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 22, fontWeight: 800, color, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BetRow({ bet, onSettle }: { bet: Bet; onSettle: (id: string, status: 'won' | 'lost' | 'push' | 'void') => void }) {
|
||||
const [openSettle, setOpenSettle] = useState(false);
|
||||
const profit = bet.status === 'won' ? bet.potential_payout - bet.amount : 0;
|
||||
const statusColor = bet.status === 'won' ? 'var(--grade-a)' : bet.status === 'lost' ? 'var(--grade-d)' : 'var(--text-secondary)';
|
||||
return (
|
||||
<div className="surface" style={{ padding: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
|
||||
{(bet.slip_data?.legs || []).map((l) =>
|
||||
`${l.player ?? '—'} ${l.direction?.charAt(0).toUpperCase()}${l.line ?? ''} ${l.stat_type ?? ''}`
|
||||
).join(' / ') || `${bet.bet_type} bet`}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{bet.book} · ${bet.amount} · {new Date(bet.placed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: 'var(--bg-elevated)', color: statusColor }}>
|
||||
{bet.status.toUpperCase()}
|
||||
</span>
|
||||
{bet.status === 'won' && (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--grade-a)', marginTop: 4 }}>
|
||||
+${profit.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bet.status === 'pending' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{openSettle ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['won', 'lost', 'push', 'void'] as const).map((s) => (
|
||||
<button key={s} onClick={() => { setOpenSettle(false); onSettle(bet.id, s); }}
|
||||
className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11, textTransform: 'capitalize' }}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setOpenSettle(false)} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11 }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setOpenSettle(true)} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 11 }}>
|
||||
Settle bet
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { trackUpgradeClicked } from '@/lib/analytics';
|
||||
|
||||
type Cadence = 'monthly' | 'annual';
|
||||
|
||||
const PRICES: Record<Cadence, { display: string; sub: string; checkoutParam: string }> = {
|
||||
monthly: { display: '$44.99', sub: '/mo', checkoutParam: '' },
|
||||
annual: { display: '$449.99', sub: '/yr · save 17%', checkoutParam: '&cadence=annual' },
|
||||
};
|
||||
|
||||
const EARLY_FEATURES = [
|
||||
'Unlimited reads',
|
||||
'Full factor analysis (40+ signals)',
|
||||
'Kill conditions surfaced inline',
|
||||
'Cascade alerts when lineups shift',
|
||||
'Parlay leg history with grades',
|
||||
'Sportsbook deep links',
|
||||
];
|
||||
|
||||
const DESK_EXTRAS = [
|
||||
'Real-time multi-book odds screen',
|
||||
'Live probability tracker during games',
|
||||
'Middles detection',
|
||||
'Correlation explorer',
|
||||
'Fair odds comparison (VYNDR vs book)',
|
||||
'Kelly sizing recommendations',
|
||||
'Cascade alerts with full detail',
|
||||
'Priority support',
|
||||
];
|
||||
|
||||
export default function DeskUpgradePage() {
|
||||
const router = useRouter();
|
||||
const { tier } = useAuth();
|
||||
const [cadence, setCadence] = useState<Cadence>('monthly');
|
||||
const price = PRICES[cadence];
|
||||
|
||||
const upgrade = () => {
|
||||
trackUpgradeClicked({ current_tier: tier, target_tier: 'desk', trigger_location: 'desk_upgrade_page' });
|
||||
router.push(`/api/checkout?tier=desk${price.checkoutParam}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 980, margin: '0 auto', padding: '40px 16px 120px' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-a)', marginBottom: 12 }}>UPGRADE</p>
|
||||
<h1 style={{ fontSize: 'clamp(28px, 4vw, 40px)', fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
The full terminal.
|
||||
</h1>
|
||||
<p style={{ marginTop: 8, color: 'var(--text-1)', fontSize: 16, maxWidth: 620, marginInline: 'auto' }}>
|
||||
Everything in Early Intelligence, plus real-time odds, live probability, and the tools the sharps use.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
display: 'grid',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--text-1)' }}>BLOOMBERG ODDS SCREEN · PREVIEW</p>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
margin: '8px auto 0',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||
gap: 4,
|
||||
maxWidth: 720,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => {
|
||||
const sample = ['—', '2.5', '+108', '-120', 'A-', 'A+'][i % 6];
|
||||
const isGrade = sample === 'A-' || sample === 'A+';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="num"
|
||||
style={{
|
||||
padding: '8px 4px',
|
||||
fontSize: 12,
|
||||
background: i % 6 === 5 ? 'rgba(0, 212, 160, 0.08)' : 'var(--bg-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
color: isGrade ? 'var(--grade-a)' : 'var(--text-1)',
|
||||
textShadow: isGrade ? '0 0 8px rgba(0, 212, 160, 0.55)' : 'none',
|
||||
}}
|
||||
>
|
||||
{sample}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TierCard
|
||||
label="Early Intelligence"
|
||||
price="$14.99"
|
||||
period="/mo"
|
||||
subtitle="Your current plan"
|
||||
features={EARLY_FEATURES}
|
||||
subdued
|
||||
/>
|
||||
<TierCard
|
||||
label="Desk"
|
||||
price={price.display}
|
||||
period={price.sub}
|
||||
subtitle="Everything above, plus:"
|
||||
features={DESK_EXTRAS}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: 'center',
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div role="tablist" aria-label="Billing cadence" style={{ display: 'inline-flex', gap: 4, background: 'var(--bg-2)', borderRadius: 999, padding: 4 }}>
|
||||
{(['monthly', 'annual'] as Cadence[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
role="tab"
|
||||
aria-selected={cadence === c}
|
||||
onClick={() => setCadence(c)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
background: cadence === c ? 'var(--bg-3)' : 'transparent',
|
||||
color: cadence === c ? 'var(--text-0)' : 'var(--text-1)',
|
||||
fontFamily: 'IBM Plex Mono, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{c.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 40,
|
||||
color: 'var(--grade-a)',
|
||||
textShadow: '0 0 18px rgba(0, 212, 160, 0.7)',
|
||||
lineHeight: 1,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{price.display}
|
||||
<span style={{ fontSize: 14, color: 'var(--text-1)' }}>{price.sub}</span>
|
||||
</p>
|
||||
|
||||
{tier !== 'desk' && (
|
||||
<p className="lbl" style={{ color: 'var(--text-1)' }}>
|
||||
You're currently on {tier === 'analyst' ? 'Early Intelligence' : 'Free'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
style={{ padding: '14px 32px', fontSize: 15, marginTop: 4 }}
|
||||
onClick={upgrade}
|
||||
>
|
||||
Upgrade to Desk →
|
||||
</button>
|
||||
|
||||
<p style={{ color: 'var(--text-2)', fontSize: 12 }}>
|
||||
Questions? <a href="mailto:support@vyndr.app" style={{ color: 'var(--text-1)' }}>support@vyndr.app</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCard({
|
||||
label,
|
||||
price,
|
||||
period,
|
||||
subtitle,
|
||||
features,
|
||||
subdued,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
price: string;
|
||||
period: string;
|
||||
subtitle: string;
|
||||
features: string[];
|
||||
subdued?: boolean;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={highlight ? 'surface diagonal-cut tex-scan' : 'surface'}
|
||||
style={{
|
||||
padding: 24,
|
||||
opacity: subdued ? 0.85 : 1,
|
||||
borderColor: highlight ? 'var(--grade-a)' : undefined,
|
||||
boxShadow: highlight ? '0 0 0 1px rgba(0,212,160,0.25), 0 12px 40px rgba(0,212,160,0.15)' : undefined,
|
||||
}}
|
||||
>
|
||||
<p className="lbl" style={{ color: highlight ? 'var(--grade-a)' : 'var(--text-1)' }}>{label.toUpperCase()}</p>
|
||||
<p
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 28,
|
||||
marginTop: 6,
|
||||
color: highlight ? 'var(--grade-a)' : 'var(--text-0)',
|
||||
textShadow: highlight ? '0 0 14px rgba(0, 212, 160, 0.55)' : 'none',
|
||||
}}
|
||||
>
|
||||
{price}<span style={{ fontSize: 13, color: 'var(--text-1)' }}>{period}</span>
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-1)', marginTop: 4 }}>{subtitle}</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}>
|
||||
{features.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}>
|
||||
<span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'IBM Plex Mono, monospace' }}>›</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
function VerifyInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const emailFromQuery = search.get('email') || '';
|
||||
const { user } = useAuth();
|
||||
const [email, setEmail] = useState(emailFromQuery || user?.email || '');
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [resentAt, setResentAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.email_confirmed_at) {
|
||||
router.replace('/welcome');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const id = setInterval(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [cooldown]);
|
||||
|
||||
const resend = async () => {
|
||||
setError('');
|
||||
if (!email) return setError('Enter the email you signed up with.');
|
||||
if (cooldown > 0) return;
|
||||
setSending(true);
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) {
|
||||
setSending(false);
|
||||
return setError('Auth is not configured.');
|
||||
}
|
||||
const { error: err } = await supabase.auth.resend({ type: 'signup', email });
|
||||
setSending(false);
|
||||
if (err) return setError(err.message);
|
||||
setResentAt(Date.now());
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
||||
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={26} />
|
||||
</a>
|
||||
|
||||
<div style={{ textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}>
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="var(--grade-a)" strokeWidth="1.5" />
|
||||
<path d="M3 7l9 6 9-6" stroke="var(--grade-a)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700 }}>Check your inbox</h1>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 320 }}>
|
||||
We sent a verification link to{' '}
|
||||
<span className="mono" style={{ color: 'var(--text-0)' }}>{email || 'your email'}</span>.
|
||||
</p>
|
||||
|
||||
{!emailFromQuery && !user && (
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@vyndr.app"
|
||||
className="input-field"
|
||||
style={{ marginTop: 8, maxWidth: 280 }}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13 }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={sending || cooldown > 0}
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{sending ? 'Sending…' : cooldown > 0 ? `Resend in ${cooldown}s` : "Didn't get it? Resend"}
|
||||
</button>
|
||||
|
||||
{resentAt ? (
|
||||
<p className="lbl" style={{ color: 'var(--grade-a)', marginTop: 4 }}>SENT</p>
|
||||
) : null}
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-1)', marginTop: 16 }}>
|
||||
Wrong email?{' '}
|
||||
<a href="/signup" style={{ color: 'var(--grade-a)' }}>Sign up again</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<VerifyInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function WelcomePage() {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace('/signup');
|
||||
}, [loading, user, router]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tex-scan"
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: 'calc(100vh - 144px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '40px 24px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span className="v-watermark" aria-hidden>V</span>
|
||||
|
||||
<SlashedYMark />
|
||||
|
||||
<h1
|
||||
style={{
|
||||
marginTop: 32,
|
||||
fontSize: 'clamp(26px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--text-0)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Welcome to VYNDR.
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: 'var(--text-1)',
|
||||
maxWidth: 460,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Intelligence the books don't want you to have.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 28, display: 'grid', gap: 12, zIndex: 1 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
style={{ padding: '14px 32px', fontSize: 15 }}
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Get Started →
|
||||
</button>
|
||||
<p className="lbl" style={{ color: 'var(--text-2)' }}>BUILT IN DETROIT</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashedYMark() {
|
||||
return (
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
aria-hidden
|
||||
style={{
|
||||
filter: 'drop-shadow(0 0 18px rgba(0, 212, 160, 0.55)) drop-shadow(0 0 36px rgba(0, 212, 160, 0.25))',
|
||||
animation: 'phosphor-pulse 2.4s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ymark-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#00FFB8" />
|
||||
<stop offset="100%" stopColor="#00D4A0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke="url(#ymark-grad)" strokeWidth="10" strokeLinecap="round" fill="none">
|
||||
<line x1="20" y1="20" x2="60" y2="64" />
|
||||
<line x1="60" y1="64" x2="60" y2="100" />
|
||||
<line x1="8" y1="110" x2="112" y2="6" strokeWidth="9" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user