Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge
This commit is contained in:
@@ -1,73 +1,100 @@
|
||||
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);
|
||||
}
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const VALID_TIERS = new Set(['analyst', 'desk']);
|
||||
|
||||
/**
|
||||
* Checkout proxy — Next.js → Express → Stripe.
|
||||
*
|
||||
* Session 8 cutover: previously this route created NexaPay payment
|
||||
* links; now it forwards to the Express `/api/stripe/checkout` route
|
||||
* (Session 3.4 + 7i) which creates a Stripe Checkout Session
|
||||
* server-side. The browser never sees `sk_test_*` / `sk_live_*` —
|
||||
* only the resulting `https://checkout.stripe.com/...` redirect URL.
|
||||
*
|
||||
* Response shape — preserves the existing `{ url }` field so older
|
||||
* Pricing CTA code that read `.url` keeps working. Express returns
|
||||
* `{ checkout_url, session_id }`; we rename and forward both so
|
||||
* either field name resolves on the client.
|
||||
*/
|
||||
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;
|
||||
// Tier resolution — query string for GET (button hrefs), body for POST.
|
||||
let tier: string | null = null;
|
||||
let founderCode: string | undefined;
|
||||
const url = new URL(req.url);
|
||||
const queryTier = url.searchParams.get('tier');
|
||||
if (queryTier) tier = queryTier;
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = (await req.json().catch(() => ({}))) as { tier?: string; founder_code?: string };
|
||||
if (body.tier) tier = body.tier;
|
||||
if (body.founder_code) founderCode = body.founder_code;
|
||||
} catch {
|
||||
/* tier may still be on the query string */
|
||||
}
|
||||
}
|
||||
if (!tier || !VALID_TIERS.has(tier)) {
|
||||
return jsonError(400, 'Pick a valid tier (analyst or desk).');
|
||||
}
|
||||
|
||||
const pricing = TIER_PRICING[tier];
|
||||
const amount = founderEligible ? pricing.founder : pricing.regular;
|
||||
// Forward to Express. The bearer token from the browser is the same
|
||||
// one Express's requireAuth verifies — no token rewriting on this hop.
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) return jsonError(401, 'Log in to upgrade.');
|
||||
|
||||
try {
|
||||
const link = await createPaymentLink({
|
||||
userId: user.id,
|
||||
tier,
|
||||
amount,
|
||||
description: `${pricing.label}${founderEligible ? ' (Founder)' : ''}`,
|
||||
founderPricing: founderEligible,
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/stripe/checkout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: JSON.stringify({ tier, ...(founderCode ? { founder_code: founderCode } : {}) }),
|
||||
});
|
||||
|
||||
// For GET (used by Pricing CTA links), redirect directly.
|
||||
if (req.method === 'GET') {
|
||||
return NextResponse.redirect(link.url, { status: 303 });
|
||||
const data = (await upstream.json().catch(() => ({}))) as {
|
||||
checkout_url?: string;
|
||||
session_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data.error || 'Checkout creation failed. Try again in a moment.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: link.url, expires_at: link.expires_at, founder_pricing: founderEligible });
|
||||
} catch (err) {
|
||||
console.error('[checkout] NexaPay link failed', err);
|
||||
const checkoutUrl = data.checkout_url;
|
||||
if (!checkoutUrl) {
|
||||
// Defensive: Express returned 200 with no URL — should never happen,
|
||||
// but if it does we don't want to silently redirect to undefined.
|
||||
return jsonError(502, 'Checkout creation incomplete. Try again.');
|
||||
}
|
||||
|
||||
// GET requests (used by legacy <a> hrefs) redirect directly so a
|
||||
// plain link click flows to Stripe without JS.
|
||||
if (req.method === 'GET') {
|
||||
return NextResponse.redirect(checkoutUrl, { status: 303 });
|
||||
}
|
||||
|
||||
// POST returns JSON so the new Pricing onClick handler can navigate
|
||||
// explicitly (gives us a place to show loading state first).
|
||||
return NextResponse.json({
|
||||
url: checkoutUrl,
|
||||
checkout_url: checkoutUrl,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
} catch {
|
||||
return jsonError(502, 'Payment processor is unreachable. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) { return handle(req); }
|
||||
export async function POST(req: NextRequest) { return handle(req); }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
// Frozen at the same set Express validates against
|
||||
// (`src/services/oddsService.js SOCCER_SPORT_KEYS`). Duplicated here so
|
||||
// a typo'd league bounces at the Next layer without burning a backend
|
||||
// round-trip.
|
||||
const VALID_LEAGUES = new Set([
|
||||
'wc', 'epl', 'laliga', 'bundesliga', 'seriea',
|
||||
'ligue1', 'ucl', 'mls', 'ligamx',
|
||||
]);
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ league: string }> }) {
|
||||
const { league } = await params;
|
||||
const leagueLc = String(league || '').toLowerCase();
|
||||
if (!VALID_LEAGUES.has(leagueLc)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown soccer league. Valid: ${[...VALID_LEAGUES].join(', ')}.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Pass the original query string through (filters: book, stat_type).
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/odds/soccer/${leagueLc}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Odds service is unreachable. Try again in a moment.' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,22 @@ 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_SPORTS = new Set(['NBA', 'MLB', 'WNBA', 'Soccer']);
|
||||
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',
|
||||
]);
|
||||
const VALID_SOCCER_STATS = new Set([
|
||||
'goals', 'assists', 'shots_on_target', 'shots', 'tackles',
|
||||
'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
|
||||
]);
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ScanBody {
|
||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
||||
sport: 'NBA' | 'MLB' | 'WNBA' | 'Soccer';
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
@@ -45,7 +49,10 @@ export async function POST(req: NextRequest) {
|
||||
return jsonError(400, 'Line must be a number between 0 and 500.');
|
||||
}
|
||||
|
||||
const validStats = body.sport === 'MLB' ? VALID_MLB_STATS : VALID_NBA_STATS;
|
||||
const validStats =
|
||||
body.sport === 'MLB' ? VALID_MLB_STATS :
|
||||
body.sport === 'Soccer' ? VALID_SOCCER_STATS :
|
||||
VALID_NBA_STATS;
|
||||
if (!validStats.has(body.stat)) {
|
||||
return jsonError(400, `Stat "${body.stat}" not supported for ${body.sport}.`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import SportSelector, { SoccerLeague, SportSelection } from '@/components/SportSelector';
|
||||
import SoccerGradeResult, { SoccerGradeResultProps } from '@/components/SoccerGradeResult';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* /soccer — live soccer odds feed.
|
||||
*
|
||||
* Shows match cards for the selected league (defaults to World Cup
|
||||
* 2026). Each match expands to reveal player props grouped by stat
|
||||
* type. Clicking a prop hands it off to /scan for grading.
|
||||
*
|
||||
* Data path: this page → /api/odds/soccer/:league → Express
|
||||
* /api/odds/soccer/:league → odds-api. The Express route falls back
|
||||
* to cache when the API quota is low; the response carries `source:
|
||||
* 'cache' | 'live'` so we can tag the freshness.
|
||||
*/
|
||||
|
||||
interface NormalizedProp {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
book: string;
|
||||
odds: number;
|
||||
game_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
fetched_at?: string;
|
||||
}
|
||||
|
||||
interface GroupedProp {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
game_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
// best line per direction across books
|
||||
over?: { book: string; odds: number };
|
||||
under?: { book: string; odds: number };
|
||||
}
|
||||
|
||||
interface OddsResponse {
|
||||
sport: string;
|
||||
updated_at?: string;
|
||||
source?: string;
|
||||
quota_remaining?: number;
|
||||
props: GroupedProp[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Group a flat props array by (player, stat_type, line) so each row
|
||||
// represents a SINGLE prop with both directions next to each other.
|
||||
// The Express response already does some grouping but ships per-direction
|
||||
// rows — collapse them.
|
||||
function groupProps(props: GroupedProp[]): GroupedProp[] {
|
||||
return props || [];
|
||||
}
|
||||
|
||||
// Group props under their match for the card layout.
|
||||
function groupByMatch(props: GroupedProp[]) {
|
||||
const matches = new Map<string, { home: string; away: string; time?: string; propsByStatType: Map<string, GroupedProp[]> }>();
|
||||
for (const p of props) {
|
||||
const home = p.home_team || '?';
|
||||
const away = p.away_team || '?';
|
||||
const key = `${home}__${away}__${p.game_time || ''}`;
|
||||
if (!matches.has(key)) {
|
||||
matches.set(key, { home, away, time: p.game_time, propsByStatType: new Map() });
|
||||
}
|
||||
const m = matches.get(key)!;
|
||||
const list = m.propsByStatType.get(p.stat_type) || [];
|
||||
list.push(p);
|
||||
m.propsByStatType.set(p.stat_type, list);
|
||||
}
|
||||
return Array.from(matches.values());
|
||||
}
|
||||
|
||||
const STAT_LABELS: Record<string, string> = {
|
||||
goals: 'Anytime / Total Goals',
|
||||
shots_on_target: 'Shots on Target',
|
||||
shots: 'Total Shots',
|
||||
tackles: 'Tackles',
|
||||
cards: 'Cards',
|
||||
corners: 'Corners',
|
||||
saves: 'Saves',
|
||||
goals_conceded: 'Goals Conceded',
|
||||
passes: 'Passes',
|
||||
clean_sheet: 'Clean Sheet',
|
||||
assists: 'Assists',
|
||||
};
|
||||
|
||||
function formatTime(iso?: string) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: 'short', hour: 'numeric', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SoccerOddsPage() {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
const [selection, setSelection] = useState<SportSelection>({ sport: 'Soccer', league: 'wc' });
|
||||
const [data, setData] = useState<OddsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<SoccerGradeResultProps | null>(null);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
|
||||
const league: SoccerLeague = selection.league || 'wc';
|
||||
|
||||
// Redirect non-Soccer sport selections back to /scan — that page
|
||||
// owns NBA/MLB/WNBA. Soccer is the only one this page serves.
|
||||
useEffect(() => {
|
||||
if (selection.sport !== 'Soccer') {
|
||||
router.push('/scan');
|
||||
}
|
||||
}, [selection.sport, router]);
|
||||
|
||||
async function gradeProp(player: string, stat_type: string, lineVal: number) {
|
||||
setScanError(null);
|
||||
setScanResult(null);
|
||||
setScanning(true);
|
||||
try {
|
||||
const res = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sport: 'Soccer',
|
||||
player,
|
||||
stat: stat_type,
|
||||
line: lineVal,
|
||||
direction: 'over',
|
||||
book: 'draftkings',
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
|
||||
if (!res.ok) {
|
||||
setScanError(body.error || 'The engine hit a wall. Try that read again.');
|
||||
setScanning(false);
|
||||
return;
|
||||
}
|
||||
const result: SoccerGradeResultProps = {
|
||||
player,
|
||||
stat_type,
|
||||
line: lineVal,
|
||||
direction: 'over',
|
||||
league,
|
||||
grade: String(body.grade || 'C'),
|
||||
confidence: typeof body.confidence === 'number' ? body.confidence : undefined,
|
||||
edge_pct: typeof body.edge_pct === 'number' ? body.edge_pct : undefined,
|
||||
reasoning: (body.reasoning as SoccerGradeResultProps['reasoning']) || undefined,
|
||||
kill_conditions_triggered: (body.kill_conditions_triggered as SoccerGradeResultProps['kill_conditions_triggered']) || [],
|
||||
tier_gated: !!body.tier_gated,
|
||||
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
|
||||
onUpgradeClick: () => router.push('/#pricing'),
|
||||
onClose: () => setScanResult(null),
|
||||
};
|
||||
setScanResult(result);
|
||||
} catch {
|
||||
setScanError('Network error. Try again.');
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOdds = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/odds/soccer/${league}`, { cache: 'no-store' });
|
||||
const body = (await res.json().catch(() => ({}))) as OddsResponse;
|
||||
if (!res.ok) {
|
||||
setError(body.error || 'Couldn’t load odds. Try again.');
|
||||
setData(null);
|
||||
} else {
|
||||
setData(body);
|
||||
}
|
||||
} catch {
|
||||
setError('Network error. Try again.');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [league]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOdds();
|
||||
}, [fetchOdds]);
|
||||
|
||||
const matches = data ? groupByMatch(groupProps(data.props)) : [];
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '24px 16px 80px' }}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 'clamp(24px, 3vw, 36px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 8 }}>
|
||||
Soccer odds
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Live odds across our launch leagues. Click any prop to grade it through the VYNDR engine.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<SportSelector
|
||||
initialSport="Soccer"
|
||||
initialLeague={league}
|
||||
onChange={(sel) => setSelection(sel)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data?.source && (
|
||||
<p
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 16,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{data.updated_at ? `Updated ${formatTime(data.updated_at)} · ` : ''}
|
||||
source: {data.source}
|
||||
{typeof data.quota_remaining === 'number' ? ` · quota: ${data.quota_remaining}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary)' }}>Loading odds…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: 16,
|
||||
border: '1px solid var(--grade-d, #ff5a5a)',
|
||||
color: 'var(--grade-d, #ff5a5a)',
|
||||
borderRadius: 8,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanError && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: 12,
|
||||
border: '1px solid var(--grade-d, #ff5a5a)',
|
||||
color: 'var(--grade-d, #ff5a5a)',
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{scanError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<SoccerGradeResult {...scanResult} />
|
||||
)}
|
||||
|
||||
{!loading && !error && matches.length === 0 && (
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 32,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
No live matches with props in this league right now.
|
||||
{league !== 'wc' && (
|
||||
<p style={{ fontSize: 13, marginTop: 8, color: 'var(--text-tertiary)' }}>
|
||||
Off-season or between matchdays — World Cup props are running through July 19, 2026.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{matches.map((m, idx) => {
|
||||
const matchKey = `${m.home}-${m.away}-${idx}`;
|
||||
const isOpen = expanded.has(matchKey);
|
||||
return (
|
||||
<article
|
||||
key={matchKey}
|
||||
className="surface diagonal-cut"
|
||||
style={{
|
||||
padding: 20,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(matchKey)) next.delete(matchKey);
|
||||
else next.add(matchKey);
|
||||
setExpanded(next);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||||
{m.away} <span style={{ color: 'var(--text-tertiary)' }}>vs</span> {m.home}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, letterSpacing: '0.06em' }}>
|
||||
{formatTime(m.time)} · {m.propsByStatType.size} stat type(s)
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 14 }}>{isOpen ? '−' : '+'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ marginTop: 16, display: 'grid', gap: 12 }}>
|
||||
{Array.from(m.propsByStatType.entries()).map(([statType, list]) => (
|
||||
<section key={statType}>
|
||||
<h3
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--grade-a)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{STAT_LABELS[statType] || statType}
|
||||
</h3>
|
||||
<ul style={{ display: 'grid', gap: 6 }}>
|
||||
{list.slice(0, 8).map((p, j) => (
|
||||
<li
|
||||
key={`${p.player}-${p.line}-${j}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-elevated)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{p.player}</span>
|
||||
<span className="mono" style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
{p.line.toFixed(1)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gradeProp(p.player, statType, p.line)}
|
||||
disabled={scanning}
|
||||
className="btn-ghost"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
cursor: scanning ? 'not-allowed' : 'pointer',
|
||||
opacity: scanning ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{scanning ? '…' : 'Grade'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* Stripe cancel landing — Stripe sends users here when they bail out
|
||||
* of checkout. We don't gate, judge, or guilt; just acknowledge and
|
||||
* point them back to pricing.
|
||||
*/
|
||||
export default function UpgradeCancelPage() {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<article
|
||||
className="surface diagonal-cut"
|
||||
style={{
|
||||
maxWidth: 560,
|
||||
width: '100%',
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
|
||||
Checkout cancelled.
|
||||
</h1>
|
||||
<p style={{ fontSize: 15, color: 'var(--text-secondary)', marginBottom: 32 }}>
|
||||
Your account is unchanged. No card was charged.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Link href="/#pricing" className="btn-primary" style={{ padding: 14, width: '100%' }}>
|
||||
Back to pricing
|
||||
</Link>
|
||||
<Link href="/scan" className="btn-ghost" style={{ padding: 14, width: '100%' }}>
|
||||
Keep using the free tier
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* Stripe checkout success landing.
|
||||
*
|
||||
* Stripe redirects to /upgrade/success?session_id={CHECKOUT_SESSION_ID}
|
||||
* after a completed checkout. Webhook events (handled server-side in
|
||||
* `src/services/stripeService.handleWebhookEvent`) write the user's
|
||||
* new tier into Supabase — there's nothing for the client to do here
|
||||
* besides confirm the redirect and refresh the auth context so the
|
||||
* new tier shows up on subsequent reads.
|
||||
*
|
||||
* We deliberately do NOT verify the session against Stripe from the
|
||||
* client (no secret key on the browser). The webhook is the source of
|
||||
* truth; this page just acknowledges the user landed.
|
||||
*/
|
||||
function SuccessInner() {
|
||||
const search = useSearchParams();
|
||||
const sessionId = search.get('session_id');
|
||||
const { refresh, profile } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Force a profile re-read so the new tier flips in the UI without
|
||||
// requiring a manual sign-out/in.
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const tierLabel = profile?.tier === 'desk' ? 'Desk' : profile?.tier === 'analyst' ? 'Analyst' : '';
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
|
||||
<article
|
||||
className="surface diagonal-cut"
|
||||
style={{
|
||||
maxWidth: 560,
|
||||
width: '100%',
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--grade-a)',
|
||||
background: 'var(--bg-elevated)',
|
||||
boxShadow: '0 16px 48px var(--accent-glow)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-primary)',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.08em',
|
||||
borderRadius: 999,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Founder Pricing Locked
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
|
||||
Welcome to VYNDR{tierLabel ? ` ${tierLabel}` : ''}.
|
||||
</h1>
|
||||
|
||||
<p style={{ fontSize: 16, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your beta pricing is locked for as long as you stay subscribed.
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-tertiary)', marginBottom: 32 }}>
|
||||
Cancel anytime in your profile.
|
||||
</p>
|
||||
|
||||
{sessionId && (
|
||||
<p
|
||||
className="mono"
|
||||
style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 24, wordBreak: 'break-all' }}
|
||||
>
|
||||
Session: {sessionId}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Link href="/scan" className="btn-primary" style={{ padding: 14, width: '100%' }}>
|
||||
Start scanning
|
||||
</Link>
|
||||
<Link href="/profile" className="btn-ghost" style={{ padding: 14, width: '100%' }}>
|
||||
View account
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpgradeSuccessPage() {
|
||||
return (
|
||||
<Suspense fallback={<main style={{ padding: 40, textAlign: 'center' }}>Loading…</main>}>
|
||||
<SuccessInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user