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}.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user