Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge
This commit is contained in:
+105
-1
@@ -4,7 +4,111 @@
|
|||||||
2026-06-10
|
2026-06-10
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v7.2 — Soccer Intelligence + World Cup 2026 (Session 7j)
|
SHIP BUILD v8.0 — Frontend Stripe Cutover + Soccer Pages (Session 8)
|
||||||
|
|
||||||
|
## Session 8 (2026-06-10) — SHIPPED
|
||||||
|
|
||||||
|
Frontend layer that connects users to the Session 7h–7j backend.
|
||||||
|
NexaPay → Stripe cutover on the pricing flow + a `/soccer` page that
|
||||||
|
exposes the soccer intelligence pipeline.
|
||||||
|
|
||||||
|
### Files created (frontend)
|
||||||
|
- `web/src/app/api/odds/soccer/[league]/route.ts` — Next.js proxy →
|
||||||
|
Express `GET /api/odds/soccer/:league`. Validates league against the
|
||||||
|
9 accepted codes upstream so a typo bounces at the Next boundary.
|
||||||
|
- `web/src/app/soccer/page.tsx` — live soccer odds feed. Hosts
|
||||||
|
`SportSelector`, fetches `/api/odds/soccer/:league`, groups props by
|
||||||
|
match → stat type. "Grade" button triggers inline scan via
|
||||||
|
`/api/scan` (sport: Soccer) and renders the result through
|
||||||
|
`SoccerGradeResult`. Soccer-only page; switching the selector to
|
||||||
|
another sport bounces to `/scan`.
|
||||||
|
- `web/src/app/upgrade/success/page.tsx` — Stripe success landing.
|
||||||
|
Reads `session_id`, refreshes AuthContext so the new tier flips
|
||||||
|
immediately. Does NOT verify against Stripe from the client (no
|
||||||
|
secret key on the browser) — the webhook is the source of truth.
|
||||||
|
- `web/src/app/upgrade/cancel/page.tsx` — Stripe cancel landing.
|
||||||
|
- `web/src/components/SportSelector.tsx` — pill tabs (NBA/WNBA/MLB/
|
||||||
|
Soccer); Soccer reveals a sub-row of the 9 league codes matching
|
||||||
|
Express's `SOCCER_SPORT_KEYS`. Emits `{ sport, league? }` via
|
||||||
|
`onChange` — pure UI, no fetches.
|
||||||
|
- `web/src/components/SoccerGradeResult.tsx` — soccer-themed result
|
||||||
|
card. Parses the engine's reasoning summary into visual chips
|
||||||
|
(⚽ goals/90, 📊 xG, 🎯 penalty taker, 🏹 free-kick taker, ⛳ corner
|
||||||
|
taker, 🏔️ altitude, 🟨 referee, ⏱️ minutes discount, 🛡️ opponent
|
||||||
|
defense, 🏆 tournament pedigree). Color-coded by tone
|
||||||
|
(positive / caution / warning / neutral). Free-tier responses
|
||||||
|
(carrying `tier_gated: true`) render the chip row blurred under an
|
||||||
|
upgrade CTA; the structured grade + confidence + edge stay visible.
|
||||||
|
Kept separate from `GradeCard` so the NBA/MLB/WNBA path is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
### Files modified (frontend)
|
||||||
|
- `web/src/app/api/checkout/route.ts` — full rewrite. Was a NexaPay
|
||||||
|
payment-link creator; is now a thin proxy that forwards `{ tier,
|
||||||
|
founder_code? }` + bearer to Express `/api/stripe/checkout`.
|
||||||
|
Response remap: `checkout_url` → `url` for callsite compat; both
|
||||||
|
fields shipped so either reads cleanly.
|
||||||
|
- `web/src/app/api/scan/route.ts` — accepts `Soccer` sport in addition
|
||||||
|
to NBA/MLB/WNBA. Soccer stat-type allowlist mirrors the backend
|
||||||
|
`VALID_STAT_TYPES` (goals, shots_on_target, shots, tackles, cards,
|
||||||
|
corners, saves, goals_conceded, passes, clean_sheet, assists).
|
||||||
|
- `web/src/components/Pricing.tsx` — CTAs converted from `<a href>` to
|
||||||
|
onClick handlers. Uses `useAuth()` for the bearer token, POSTs to
|
||||||
|
`/api/checkout`, `window.location.assign` to the returned Stripe URL.
|
||||||
|
Loading state on the active tier, inline error banner. Anonymous
|
||||||
|
visitors bounce to `/signup?return=/%23pricing`. Footnote rewritten
|
||||||
|
from "NexaPay" to "Stripe (test mode while we onboard founders)".
|
||||||
|
- `web/src/components/Nav.tsx` — small BETA tag next to the wordmark.
|
||||||
|
Glitch-styled, monospace, low-opacity green border. Renders on every
|
||||||
|
page that mounts Nav.
|
||||||
|
|
||||||
|
### Files modified (backend — ONE allowed change)
|
||||||
|
- `src/services/stripeService.js` — `success_url` / `cancel_url`
|
||||||
|
point at the frontend (`NEXT_PUBLIC_SITE_URL` with `BASE_URL`
|
||||||
|
fallback, default `http://localhost:3000`). Previously the routes
|
||||||
|
pointed at the Express origin which would have 404'd the redirect.
|
||||||
|
New URLs:
|
||||||
|
- `${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`
|
||||||
|
- `${frontendUrl}/upgrade/cancel`
|
||||||
|
All 23 Stripe tests still pass (none asserted on the URL strings).
|
||||||
|
|
||||||
|
### Files modified (docs)
|
||||||
|
- `docs/SYSTEM-MANIFEST.md` — `/api/odds/soccer/[league]` row in
|
||||||
|
Next.js routes, new section listing the three new Next.js pages,
|
||||||
|
the Session 7h "dual-provider divergence" callout flipped from
|
||||||
|
open-work to ✅ complete.
|
||||||
|
- `BUILD-STATE.md` — Session 8 entry.
|
||||||
|
|
||||||
|
### Honest verification status
|
||||||
|
|
||||||
|
Build-verified (passed `web/npm run build` after every component):
|
||||||
|
- All TypeScript types resolve
|
||||||
|
- All routes prerender / build correctly (24 pages, 30+ API routes)
|
||||||
|
- No ESLint errors
|
||||||
|
|
||||||
|
NOT runtime-verified in this session (I have no browser to click
|
||||||
|
through):
|
||||||
|
- Actual Stripe checkout redirect end-to-end (test mode card flow)
|
||||||
|
- Soccer odds rendering with live data (depends on
|
||||||
|
`FOOTBALL_DATA_API_KEY` being set in prod and the daily prefetch
|
||||||
|
having run)
|
||||||
|
- SoccerGradeResult signal parsing against a real engine response
|
||||||
|
(signal-chip regex tested against the exact phrasing
|
||||||
|
`buildSoccerReasoningLines` emits in `analyzeViaEngine1.js`, but
|
||||||
|
not against live engine output)
|
||||||
|
- AuthContext.refresh() actually triggering a profile re-read after
|
||||||
|
the Stripe redirect
|
||||||
|
|
||||||
|
These are the expected next-session sanity checks once Coolify
|
||||||
|
deploys this build.
|
||||||
|
|
||||||
|
### Quality gates
|
||||||
|
- `npm test` (backend): **1173 / 1173 passing**, 91 suites, 0 regressions
|
||||||
|
from Session 7j baseline
|
||||||
|
- `web/npm run build`: clean — all new routes prerendered, no type errors
|
||||||
|
- License audit: only permissive licenses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 7j (2026-06-10) — SHIPPED
|
## Session 7j (2026-06-10) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -439,3 +439,17 @@
|
|||||||
{"ts":"2026-06-10T18:29:14.213Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
{"ts":"2026-06-10T18:29:14.213Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
{"ts":"2026-06-10T18:29:14.229Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
{"ts":"2026-06-10T18:29:14.229Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
{"ts":"2026-06-10T18:29:14.240Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
{"ts":"2026-06-10T18:29:14.240Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T19:00:15.858Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.029Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.037Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.037Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.264Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.380Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T19:00:16.686Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T19:18:38.940Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.104Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.109Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.109Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.153Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.210Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T19:18:39.431Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
|||||||
+28
-23
@@ -99,19 +99,26 @@ Mounted in `src/app.js`. Auth column meanings:
|
|||||||
These are proxies or thin wrappers; they hit Express via `BACKEND_URL`
|
These are proxies or thin wrappers; they hit Express via `BACKEND_URL`
|
||||||
or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.
|
or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.
|
||||||
|
|
||||||
- `/api/checkout` (POST/GET) — NexaPay checkout
|
- `/api/checkout` (POST/GET) — Stripe checkout proxy (Session 8 cutover — was NexaPay)
|
||||||
- `/api/games/[id]` and `/api/games/tonight` — list / detail
|
- `/api/games/[id]` and `/api/games/tonight` — list / detail
|
||||||
- `/api/games/[id]/props` — props for a game
|
- `/api/games/[id]/props` — props for a game
|
||||||
- `/api/intelligence/feed` — homepage live signals
|
- `/api/intelligence/feed` — homepage live signals
|
||||||
- `/api/ledger`, `/api/ledger/accuracy` — Ledger feed
|
- `/api/ledger`, `/api/ledger/accuracy` — Ledger feed
|
||||||
|
- `/api/odds/soccer/[league]` — soccer odds proxy → Express `/api/odds/soccer/:league` (Session 8)
|
||||||
- `/api/parlay/add-leg`, `/api/parlay/grade` — proxy to `/api/scan/parlay`
|
- `/api/parlay/add-leg`, `/api/parlay/grade` — proxy to `/api/scan/parlay`
|
||||||
- `/api/players/search` — proxy to Python `/players/search`
|
- `/api/players/search` — proxy to Python `/players/search`
|
||||||
- `/api/props/live`, `/api/props/most-parlayed`, `/api/props/top-graded`
|
- `/api/props/live`, `/api/props/most-parlayed`, `/api/props/top-graded`
|
||||||
- `/api/scan` — bare scan endpoint
|
- `/api/scan` — bare scan endpoint (Session 8 — accepts `Soccer` sport in addition to NBA/MLB/WNBA)
|
||||||
- `/api/stats/parlays-graded`, `/api/stats/public` — proxy
|
- `/api/stats/parlays-graded`, `/api/stats/public` — proxy
|
||||||
- `/api/user/profile`, `/api/user/scans`, `/api/user/recent-scans`
|
- `/api/user/profile`, `/api/user/scans`, `/api/user/recent-scans`
|
||||||
- `/api/waitlist` — proxy
|
- `/api/waitlist` — proxy
|
||||||
- `/api/webhook/nexapay` — NexaPay webhook
|
- `/api/webhook/nexapay` — NexaPay webhook (legacy — Stripe cutover Session 8; webhook still listening for any in-flight NexaPay events)
|
||||||
|
|
||||||
|
### Next.js pages (Session 8 additions)
|
||||||
|
|
||||||
|
- `/soccer` — live soccer odds feed + inline prop grading. Hosts `SportSelector` + per-league match cards, scans selected props through `/api/scan` → Express `/api/analyze/prop` with `sport: 'Soccer'`. Results render in `SoccerGradeResult` (parses the engine's reasoning summary into visual signal chips: ⚽ goals/90, 📊 xG, 🏔️ altitude, 🟨 referee, 🎯 penalty taker, 🏆 WC pedigree). Free tier gets a blurred preview + upgrade CTA.
|
||||||
|
- `/upgrade/success` — Stripe checkout success landing. Reads `session_id` query param, refreshes the AuthContext so the new tier flips immediately. Stripe webhook is the source of truth; this page does not verify the session against Stripe (no secret key on the client).
|
||||||
|
- `/upgrade/cancel` — Stripe checkout cancel landing. No judgment, links back to `/#pricing` and `/scan`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -615,28 +622,26 @@ All frontend API paths discovered are either:
|
|||||||
handler. Spot-checked: `/api/players/search` (Next → Python),
|
handler. Spot-checked: `/api/players/search` (Next → Python),
|
||||||
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).
|
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).
|
||||||
|
|
||||||
#### Payments: dual-provider divergence (Session 7h)
|
#### Payments: Stripe cutover (Session 8 — COMPLETE)
|
||||||
|
|
||||||
The frontend `/api/checkout` (Next.js) creates **NexaPay** payment
|
The dual-provider divergence flagged in 7h is closed:
|
||||||
links and is what `web/src/components/Pricing.tsx` CTAs currently hit.
|
|
||||||
The Express `POST /api/stripe/checkout` (Stripe Checkout Sessions) is
|
|
||||||
fully wired, tested in test mode against real Stripe resources
|
|
||||||
(products + prices + webhook all created), and ready for traffic —
|
|
||||||
but no frontend caller invokes it yet. Cutover work for a follow-up
|
|
||||||
session:
|
|
||||||
|
|
||||||
1. Replace `web/src/app/api/checkout/route.ts` body to fetch
|
1. ✅ `web/src/app/api/checkout/route.ts` now forwards to
|
||||||
`${BACKEND_URL}/api/stripe/checkout` with the user's bearer token
|
`${BACKEND_URL}/api/stripe/checkout` with the user's bearer token.
|
||||||
instead of calling NexaPay's `createPaymentLink`.
|
The route remaps `{ checkout_url, session_id }` → `{ url, … }` so
|
||||||
2. Wire `Pricing.tsx` CTAs through that same Next.js route (response
|
the existing client field shape still works.
|
||||||
shape is already `{ url, ... }`-compatible; Express returns
|
2. ✅ `Pricing.tsx` CTAs were converted from `<a href>` to onClick
|
||||||
`{ checkout_url, session_id }`, so the proxy needs to remap
|
handlers that POST to `/api/checkout` and `window.location.assign`
|
||||||
`checkout_url → url`).
|
the returned Stripe URL. Loading state during redirect; error
|
||||||
3. Add `/upgrade/success?session_id=...` and `/upgrade/cancel` pages.
|
surfaced inline.
|
||||||
Current Stripe `success_url` points at `/scan?upgraded=true` and
|
3. ✅ `/upgrade/success?session_id=…` and `/upgrade/cancel` pages
|
||||||
`cancel_url` at `/#pricing` — those work but a confirmation page
|
shipped. Express `stripeService.js` updated to point `success_url`
|
||||||
reads better.
|
and `cancel_url` at the new frontend pages via `NEXT_PUBLIC_SITE_URL`
|
||||||
4. Decide on NexaPay: keep as fallback, remove, or feature-flag.
|
(the only backend file touched in Session 8).
|
||||||
|
4. NexaPay is still wired but no UI calls it. Disposition (remove vs
|
||||||
|
keep as fallback) is a follow-up call — leaving it in place doesn't
|
||||||
|
cost anything and gives the team a fallback if Stripe goes down
|
||||||
|
during the World Cup window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,13 +57,21 @@ async function createCheckoutSession(userId, email, tier, founderCode) {
|
|||||||
.eq('id', userId);
|
.eq('id', userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
// Stripe sends the user to a FRONTEND URL after checkout — not the
|
||||||
|
// Express API. NEXT_PUBLIC_SITE_URL is the canonical frontend origin
|
||||||
|
// (defaults to https://vyndr.app per the email templates), with
|
||||||
|
// BASE_URL as a fallback for legacy deploys that only set the API
|
||||||
|
// origin. localhost:3000 is the Next dev server default; Express
|
||||||
|
// dev runs on 3001 so we never want to send users there.
|
||||||
|
const frontendUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||||
|
|| process.env.BASE_URL
|
||||||
|
|| 'http://localhost:3000';
|
||||||
const session = await getStripe().checkout.sessions.create({
|
const session = await getStripe().checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
success_url: `${baseUrl}/scan?upgraded=true`,
|
success_url: `${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${baseUrl}/#pricing`,
|
cancel_url: `${frontendUrl}/upgrade/cancel`,
|
||||||
metadata: { user_id: userId, tier, is_founder: String(isFounder) },
|
metadata: { user_id: userId, tier, is_founder: String(isFounder) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,73 +1,100 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const VALID_TIERS = new Set<NexaPayTier>(['analyst', 'desk']);
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
const VALID_TIERS = new Set(['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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
async function handle(req: NextRequest) {
|
||||||
const user = await getUserFromRequest(req);
|
const user = await getUserFromRequest(req);
|
||||||
if (!user) return jsonError(401, 'Log in to upgrade.');
|
if (!user) return jsonError(401, 'Log in to upgrade.');
|
||||||
|
|
||||||
const tier = await resolveTier(req);
|
// Tier resolution — query string for GET (button hrefs), body for POST.
|
||||||
if (!tier) return jsonError(400, 'Pick a valid tier (analyst or desk).');
|
let tier: string | null = null;
|
||||||
|
let founderCode: string | undefined;
|
||||||
// Founder pricing eligibility — first 100 paid users overall
|
const url = new URL(req.url);
|
||||||
let founderEligible = false;
|
const queryTier = url.searchParams.get('tier');
|
||||||
const sb = getServiceRoleSupabase();
|
if (queryTier) tier = queryTier;
|
||||||
if (sb) {
|
if (req.method === 'POST') {
|
||||||
const { count } = await sb
|
try {
|
||||||
.from('user_profiles')
|
const body = (await req.json().catch(() => ({}))) as { tier?: string; founder_code?: string };
|
||||||
.select('id', { count: 'exact', head: true })
|
if (body.tier) tier = body.tier;
|
||||||
.eq('founder_pricing', true);
|
if (body.founder_code) founderCode = body.founder_code;
|
||||||
founderEligible = (count ?? 0) < 100;
|
} 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];
|
// Forward to Express. The bearer token from the browser is the same
|
||||||
const amount = founderEligible ? pricing.founder : pricing.regular;
|
// 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 {
|
try {
|
||||||
const link = await createPaymentLink({
|
const upstream = await fetch(`${BACKEND_URL}/api/stripe/checkout`, {
|
||||||
userId: user.id,
|
method: 'POST',
|
||||||
tier,
|
headers: {
|
||||||
amount,
|
'Content-Type': 'application/json',
|
||||||
description: `${pricing.label}${founderEligible ? ' (Founder)' : ''}`,
|
Authorization: authHeader,
|
||||||
founderPricing: founderEligible,
|
},
|
||||||
|
body: JSON.stringify({ tier, ...(founderCode ? { founder_code: founderCode } : {}) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// For GET (used by Pricing CTA links), redirect directly.
|
const data = (await upstream.json().catch(() => ({}))) as {
|
||||||
if (req.method === 'GET') {
|
checkout_url?: string;
|
||||||
return NextResponse.redirect(link.url, { status: 303 });
|
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 });
|
const checkoutUrl = data.checkout_url;
|
||||||
} catch (err) {
|
if (!checkoutUrl) {
|
||||||
console.error('[checkout] NexaPay link failed', err);
|
// 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.');
|
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) =>
|
const isSameMonth = (date: string | null | undefined) =>
|
||||||
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
!!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_DIRECTIONS = new Set(['over', 'under']);
|
||||||
const VALID_NBA_STATS = new Set(['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers']);
|
const VALID_NBA_STATS = new Set(['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers']);
|
||||||
const VALID_MLB_STATS = new Set([
|
const VALID_MLB_STATS = new Set([
|
||||||
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
||||||
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
|
'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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
interface ScanBody {
|
interface ScanBody {
|
||||||
sport: 'NBA' | 'MLB' | 'WNBA';
|
sport: 'NBA' | 'MLB' | 'WNBA' | 'Soccer';
|
||||||
player: string;
|
player: string;
|
||||||
stat: string;
|
stat: string;
|
||||||
line: number;
|
line: number;
|
||||||
@@ -45,7 +49,10 @@ export async function POST(req: NextRequest) {
|
|||||||
return jsonError(400, 'Line must be a number between 0 and 500.');
|
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)) {
|
if (!validStats.has(body.stat)) {
|
||||||
return jsonError(400, `Stat "${body.stat}" not supported for ${body.sport}.`);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,10 +47,33 @@ export default function Nav() {
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
|
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 10 }}
|
||||||
aria-label="VYNDR — home"
|
aria-label="VYNDR — home"
|
||||||
>
|
>
|
||||||
<Wordmark size={22} />
|
<Wordmark size={22} />
|
||||||
|
{/* Session 8 — beta tag. Tiny, glitch-styled, sits next to
|
||||||
|
the wordmark so it reads as part of the brand rather than
|
||||||
|
a banner. Renders on every page that mounts Nav. */}
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
aria-label="Beta"
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
padding: '2px 5px',
|
||||||
|
color: 'var(--grade-a)',
|
||||||
|
border: '1px solid var(--grade-a)',
|
||||||
|
borderRadius: 3,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
opacity: 0.85,
|
||||||
|
lineHeight: 1,
|
||||||
|
position: 'relative',
|
||||||
|
top: -2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
||||||
|
|||||||
+105
-14
@@ -1,6 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
const TIERS = [
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
type TierId = 'free' | 'analyst' | 'desk';
|
||||||
|
|
||||||
|
interface TierConfig {
|
||||||
|
id: TierId;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
originalPrice?: string;
|
||||||
|
cadence: string;
|
||||||
|
badge?: string;
|
||||||
|
headline: string;
|
||||||
|
cta: string;
|
||||||
|
features: string[];
|
||||||
|
locked: string[];
|
||||||
|
highlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS: TierConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'free',
|
id: 'free',
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
@@ -8,7 +28,6 @@ const TIERS = [
|
|||||||
cadence: '/mo',
|
cadence: '/mo',
|
||||||
headline: 'Try the model. No card required.',
|
headline: 'Try the model. No card required.',
|
||||||
cta: 'Start Free',
|
cta: 'Start Free',
|
||||||
ctaHref: '/signup',
|
|
||||||
features: [
|
features: [
|
||||||
'5 reads per month',
|
'5 reads per month',
|
||||||
'Grade letter + projection',
|
'Grade letter + projection',
|
||||||
@@ -31,7 +50,6 @@ const TIERS = [
|
|||||||
badge: 'Founder Access',
|
badge: 'Founder Access',
|
||||||
headline: 'The full intelligence layer.',
|
headline: 'The full intelligence layer.',
|
||||||
cta: 'Lock Founder Price',
|
cta: 'Lock Founder Price',
|
||||||
ctaHref: '/api/checkout?tier=analyst',
|
|
||||||
features: [
|
features: [
|
||||||
'Unlimited reads',
|
'Unlimited reads',
|
||||||
'Full factor analysis (40+ signals)',
|
'Full factor analysis (40+ signals)',
|
||||||
@@ -54,7 +72,6 @@ const TIERS = [
|
|||||||
cadence: '/mo',
|
cadence: '/mo',
|
||||||
headline: 'Everything. The professional setup.',
|
headline: 'Everything. The professional setup.',
|
||||||
cta: 'Go Desk',
|
cta: 'Go Desk',
|
||||||
ctaHref: '/api/checkout?tier=desk',
|
|
||||||
features: [
|
features: [
|
||||||
'Everything in Analyst',
|
'Everything in Analyst',
|
||||||
'Alt line ladder + edge ranking',
|
'Alt line ladder + edge ranking',
|
||||||
@@ -62,7 +79,6 @@ const TIERS = [
|
|||||||
'Real-time intelligence feed',
|
'Real-time intelligence feed',
|
||||||
'Parlay correlation analysis (phi)',
|
'Parlay correlation analysis (phi)',
|
||||||
'Consensus vs model comparison',
|
'Consensus vs model comparison',
|
||||||
'API access (coming Q3)',
|
|
||||||
],
|
],
|
||||||
locked: [],
|
locked: [],
|
||||||
highlight: false,
|
highlight: false,
|
||||||
@@ -70,6 +86,51 @@ const TIERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Pricing() {
|
export default function Pricing() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { session, loading: authLoading } = useAuth();
|
||||||
|
const [pending, setPending] = useState<TierId | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function startCheckout(tier: TierId) {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Free tier short-circuits — no checkout, just signup.
|
||||||
|
if (tier === 'free') {
|
||||||
|
router.push('/signup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous → bounce to signup with a returnTo back to /#pricing.
|
||||||
|
if (!session) {
|
||||||
|
router.push('/signup?return=/%23pricing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(tier);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ tier }),
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
setError(data.error || 'Checkout creation failed. Try again in a moment.');
|
||||||
|
setPending(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hand off to Stripe. The success_url returns the user to
|
||||||
|
// /upgrade/success?session_id=… — no further client work needed.
|
||||||
|
window.location.assign(data.url);
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Try again.');
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="pricing"
|
id="pricing"
|
||||||
@@ -87,12 +148,33 @@ export default function Pricing() {
|
|||||||
Pricing built for bettors. Not for SaaS investors.
|
Pricing built for bettors. Not for SaaS investors.
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
||||||
First 100 users lock $14.99/mo for life. This price dies at user 101.
|
First 100 users lock $14.99/mo for life. Beta pricing — this price dies at user 101.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
maxWidth: 720,
|
||||||
|
margin: '0 auto 24px',
|
||||||
|
padding: 14,
|
||||||
|
border: '1px solid var(--grade-d, #ff5a5a)',
|
||||||
|
color: 'var(--grade-d, #ff5a5a)',
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
|
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
|
||||||
{TIERS.map((tier, i) => (
|
{TIERS.map((tier, i) => {
|
||||||
|
const isPending = pending === tier.id;
|
||||||
|
const isDisabled = authLoading || (pending !== null && !isPending);
|
||||||
|
return (
|
||||||
<article
|
<article
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
|
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
|
||||||
@@ -142,13 +224,21 @@ export default function Pricing() {
|
|||||||
{tier.headline}
|
{tier.headline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
href={tier.ctaHref}
|
type="button"
|
||||||
|
onClick={() => startCheckout(tier.id)}
|
||||||
|
disabled={isDisabled}
|
||||||
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
|
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
|
||||||
style={{ width: '100%', padding: 14, marginBottom: 24 }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 24,
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tier.cta}
|
{isPending ? 'Redirecting to Stripe…' : tier.cta}
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<ul style={{ display: 'grid', gap: 10 }}>
|
<ul style={{ display: 'grid', gap: 10 }}>
|
||||||
{tier.features.map((f) => (
|
{tier.features.map((f) => (
|
||||||
@@ -165,11 +255,12 @@ export default function Pricing() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
||||||
Cancel anytime. No contracts. Card or Apple Pay or Google Pay — payments processed by NexaPay.
|
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe (test mode while we onboard founders).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soccer result card — renders an analyze/prop response with
|
||||||
|
* soccer-specific visual treatment. We can't surface raw feature
|
||||||
|
* values (the backend response carries only `reasoning.summary` +
|
||||||
|
* `kill_conditions_triggered` per the engine1 → legacy adapter), so
|
||||||
|
* we parse the summary for known soccer-signal phrases and surface
|
||||||
|
* each as a colored chip above the prose.
|
||||||
|
*
|
||||||
|
* Free-tier responses already arrive gated (the Session 7h
|
||||||
|
* `applyTierGating` redacts `reasoning` and `kill_conditions`); we
|
||||||
|
* just need to detect the `tier_gated` / `locked` markers and show
|
||||||
|
* an upgrade CTA over the blurred content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface KillCondition {
|
||||||
|
code: string;
|
||||||
|
reason: string;
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Reasoning {
|
||||||
|
summary?: string;
|
||||||
|
steps?: unknown;
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoccerGradeResultProps {
|
||||||
|
player: string;
|
||||||
|
stat_type: string;
|
||||||
|
line: number;
|
||||||
|
direction: 'over' | 'under';
|
||||||
|
league: string;
|
||||||
|
grade: string;
|
||||||
|
confidence?: number;
|
||||||
|
edge_pct?: number;
|
||||||
|
reasoning?: Reasoning;
|
||||||
|
kill_conditions_triggered?: KillCondition[];
|
||||||
|
tier_gated?: boolean;
|
||||||
|
upgrade_hint?: string;
|
||||||
|
onUpgradeClick?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalTone = 'positive' | 'caution' | 'warning' | 'neutral';
|
||||||
|
|
||||||
|
interface ParsedSignal {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
tone: SignalTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_TONE_STYLE: Record<SignalTone, { color: string; bg: string; border: string }> = {
|
||||||
|
positive: { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.08)', border: 'rgba(0,200,150,0.40)' },
|
||||||
|
caution: { color: 'var(--grade-c, #FFB347)', bg: 'rgba(255,179,71,0.08)', border: 'rgba(255,179,71,0.40)' },
|
||||||
|
warning: { color: 'var(--grade-d, #ff5a5a)', bg: 'rgba(255,90,90,0.08)', border: 'rgba(255,90,90,0.40)' },
|
||||||
|
neutral: { color: 'var(--text-secondary)', bg: 'transparent', border: 'var(--border)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pattern-match the concrete sentences `buildSoccerReasoningLines`
|
||||||
|
// emits in src/services/intelligence/analyzeViaEngine1.js. Order
|
||||||
|
// matters — earlier patterns win when multiple match the same line.
|
||||||
|
const SIGNAL_PATTERNS: Array<(line: string) => ParsedSignal | null> = [
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/scores ([\d.]+) goals per 90 minutes/i);
|
||||||
|
if (m) return { icon: '⚽', label: 'Goals / 90', detail: `${m[1]}`, tone: 'positive' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/Expected goals \(xG\): ([\d.]+) per 90 — (.+)/i);
|
||||||
|
if (m) {
|
||||||
|
const trend = m[2].toLowerCase();
|
||||||
|
const tone: SignalTone = trend.includes('regression') ? 'caution'
|
||||||
|
: trend.includes('breakout') ? 'positive' : 'neutral';
|
||||||
|
return { icon: '📊', label: 'xG / 90', detail: `${m[1]} — ${m[2]}`, tone };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
if (/Designated penalty taker/i.test(line)) {
|
||||||
|
return { icon: '🎯', label: 'Penalty Taker', detail: '+0.15 goals/90 boost', tone: 'positive' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
if (/Direct free-kick specialist/i.test(line)) {
|
||||||
|
return { icon: '🏹', label: 'Free-Kick Taker', detail: 'shot/goal probability boost', tone: 'positive' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
if (/corner taker/i.test(line)) {
|
||||||
|
return { icon: '⛳', label: 'Corner Taker', detail: 'assist probability boost', tone: 'positive' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/Match at ([\d,]+)ft altitude\.\s*(.+)/i);
|
||||||
|
if (m) {
|
||||||
|
const isAcclimated = /acclimated host/i.test(m[2]);
|
||||||
|
return {
|
||||||
|
icon: '🏔️',
|
||||||
|
label: 'Altitude',
|
||||||
|
detail: `${m[1]}ft — ${isAcclimated ? 'host acclimated' : 'visitor risk'}`,
|
||||||
|
tone: isAcclimated ? 'neutral' : 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/(.+?) averages ([\d.]+) cards per match/i);
|
||||||
|
if (m) {
|
||||||
|
const cardsPerGame = parseFloat(m[2]);
|
||||||
|
const tone: SignalTone = cardsPerGame >= 5 ? 'caution' : 'neutral';
|
||||||
|
return { icon: '🟨', label: `Referee: ${m[1].trim()}`, detail: `${m[2]} cards/match`, tone };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/Averaging only ([\d.]+) minutes per match/i);
|
||||||
|
if (m) return { icon: '⏱️', label: 'Minutes', detail: `${m[1]}/90 — under-line discount`, tone: 'caution' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/(.+?) concedes ([\d.]+) goals per game/i);
|
||||||
|
if (m) {
|
||||||
|
const conceded = parseFloat(m[2]);
|
||||||
|
const tone: SignalTone = conceded <= 0.8 ? 'warning' : conceded >= 1.6 ? 'positive' : 'neutral';
|
||||||
|
return { icon: '🛡️', label: `Defense: ${m[1].trim()}`, detail: `${m[2]} GA/match`, tone };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(line) => {
|
||||||
|
const m = line.match(/Tournament pedigree: (\d+) career World Cup goals/i);
|
||||||
|
if (m) return { icon: '🏆', label: 'WC Pedigree', detail: `${m[1]} career goals`, tone: 'positive' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseSignals(summary: string | undefined): ParsedSignal[] {
|
||||||
|
if (!summary) return [];
|
||||||
|
const out: ParsedSignal[] = [];
|
||||||
|
// The buildSoccerReasoningLines output is a single `lines.join(' ')`,
|
||||||
|
// so split on period+space and trim. Some sentences contain periods
|
||||||
|
// (e.g. "0.67 goals per 90"), so re-match conservatively.
|
||||||
|
const fragments = summary.split(/(?<=\.)\s+(?=[A-Z⚽📊🎯🏹⛳🏔️🟨⏱️🛡️🏆])/);
|
||||||
|
for (const frag of fragments) {
|
||||||
|
for (const fn of SIGNAL_PATTERNS) {
|
||||||
|
const sig = fn(frag);
|
||||||
|
if (sig) {
|
||||||
|
out.push(sig);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeColor(grade: string): string {
|
||||||
|
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||||
|
if (g === 'A') return 'var(--grade-a)';
|
||||||
|
if (g === 'B') return 'var(--grade-b, #4A9EFF)';
|
||||||
|
if (g === 'C') return 'var(--grade-c, #FFB347)';
|
||||||
|
return 'var(--grade-d, #ff5a5a)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SoccerGradeResult(props: SoccerGradeResultProps) {
|
||||||
|
const {
|
||||||
|
player, stat_type, line, direction, league, grade, confidence, edge_pct,
|
||||||
|
reasoning, kill_conditions_triggered, tier_gated, upgrade_hint,
|
||||||
|
onUpgradeClick, onClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const signals = useMemo(() => parseSignals(reasoning?.summary), [reasoning?.summary]);
|
||||||
|
const color = gradeColor(grade);
|
||||||
|
const locked = !!tier_gated || !!reasoning?.locked;
|
||||||
|
const kills = Array.isArray(kill_conditions_triggered) ? kill_conditions_triggered : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="surface diagonal-cut"
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 16,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
data-testid="soccer-grade-result"
|
||||||
|
>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 8, right: 12,
|
||||||
|
background: 'transparent', border: 0, cursor: 'pointer',
|
||||||
|
fontSize: 18, color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>{player}</div>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
{direction.toUpperCase()} {line.toFixed(1)} {stat_type.replace(/_/g, ' ')} · {league.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 44, fontWeight: 800, color, lineHeight: 1, letterSpacing: '-0.04em' }}>
|
||||||
|
{grade}
|
||||||
|
</div>
|
||||||
|
{typeof confidence === 'number' && (
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||||
|
{confidence.toFixed(0)}% conf
|
||||||
|
{typeof edge_pct === 'number' && (
|
||||||
|
<> · {edge_pct >= 0 ? '+' : ''}{edge_pct.toFixed(1)}% edge</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{signals.length > 0 && !locked && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
||||||
|
{signals.map((sig, idx) => {
|
||||||
|
const style = SIGNAL_TONE_STYLE[sig.tone];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${sig.label}-${idx}`}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: `1px solid ${style.border}`,
|
||||||
|
background: style.bg,
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 6,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: 14 }}>{sig.icon}</span>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
color: style.color,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
fontSize: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sig.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{sig.detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!locked && reasoning?.summary && (
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: 16 }}>
|
||||||
|
{reasoning.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locked && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 20,
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.20)',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
filter: 'blur(4px)',
|
||||||
|
userSelect: 'none',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
⚽ Goals/90: 0.67 · 📊 xG: 0.52 — overperforming · 🏔️ altitude 7,349ft · 🟨 ref 4.7 cards/match · 🎯 penalty taker
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||||
|
{upgrade_hint || 'Unlock full intelligence — xG regression, altitude, referee, set-piece role.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUpgradeClick}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ padding: '8px 18px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Unlock full analysis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kills.length > 0 && (
|
||||||
|
<section style={{ marginTop: 8 }}>
|
||||||
|
<h3
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 10, color: 'var(--grade-d, #ff5a5a)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
Kill conditions ({kills.length})
|
||||||
|
</h3>
|
||||||
|
<ul style={{ display: 'grid', gap: 6 }}>
|
||||||
|
{kills.map((k, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${k.code}-${idx}`}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
border: '1px solid rgba(255,90,90,0.30)',
|
||||||
|
background: 'rgba(255,90,90,0.04)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mono" style={{ color: 'var(--grade-d, #ff5a5a)', fontWeight: 700, marginRight: 6 }}>
|
||||||
|
{k.code}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{k.reason}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SportSelector — pill tabs for the four launch verticals.
|
||||||
|
*
|
||||||
|
* Soccer reveals a secondary league pill row (WC default for the
|
||||||
|
* tournament launch; EPL/La Liga/etc available year-round). The
|
||||||
|
* selected `{ sport, league }` is emitted via `onChange` so the
|
||||||
|
* parent owns the actual scan/odds state and can refetch on switch.
|
||||||
|
*
|
||||||
|
* The component is intentionally pure-UI — no fetches, no auth, no
|
||||||
|
* persistence. A parent that wants the selection to stick should
|
||||||
|
* pass `initialSport` / `initialLeague` from URL params or
|
||||||
|
* localStorage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Sport = 'NBA' | 'WNBA' | 'MLB' | 'Soccer';
|
||||||
|
|
||||||
|
// Soccer league codes match the GET /api/odds/soccer/:league path
|
||||||
|
// segment AND the `SOCCER_LEAGUES` env on the backend. Source of truth
|
||||||
|
// is `src/services/oddsService.js SOCCER_SPORT_KEYS`.
|
||||||
|
export type SoccerLeague =
|
||||||
|
| 'wc'
|
||||||
|
| 'epl'
|
||||||
|
| 'laliga'
|
||||||
|
| 'bundesliga'
|
||||||
|
| 'seriea'
|
||||||
|
| 'ligue1'
|
||||||
|
| 'ucl'
|
||||||
|
| 'mls'
|
||||||
|
| 'ligamx';
|
||||||
|
|
||||||
|
export interface SportSelection {
|
||||||
|
sport: Sport;
|
||||||
|
league?: SoccerLeague;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORTS: Array<{ id: Sport; label: string; status?: 'live' | 'beta' }> = [
|
||||||
|
{ id: 'NBA', label: 'NBA', status: 'live' },
|
||||||
|
{ id: 'WNBA', label: 'WNBA', status: 'live' },
|
||||||
|
{ id: 'MLB', label: 'MLB', status: 'live' },
|
||||||
|
{ id: 'Soccer', label: 'Soccer', status: 'beta' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOCCER_LEAGUES: Array<{ id: SoccerLeague; label: string; sub?: string }> = [
|
||||||
|
{ id: 'wc', label: 'World Cup', sub: '2026' },
|
||||||
|
{ id: 'epl', label: 'EPL' },
|
||||||
|
{ id: 'laliga', label: 'La Liga' },
|
||||||
|
{ id: 'bundesliga', label: 'Bundesliga' },
|
||||||
|
{ id: 'seriea', label: 'Serie A' },
|
||||||
|
{ id: 'ligue1', label: 'Ligue 1' },
|
||||||
|
{ id: 'ucl', label: 'UCL' },
|
||||||
|
{ id: 'mls', label: 'MLS' },
|
||||||
|
{ id: 'ligamx', label: 'Liga MX' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialSport?: Sport;
|
||||||
|
initialLeague?: SoccerLeague;
|
||||||
|
onChange?: (selection: SportSelection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SportSelector({
|
||||||
|
initialSport = 'NBA',
|
||||||
|
initialLeague = 'wc',
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [sport, setSport] = useState<Sport>(initialSport);
|
||||||
|
const [league, setLeague] = useState<SoccerLeague>(initialLeague);
|
||||||
|
|
||||||
|
// Emit on every change so parents stay in sync. Effect (not inline
|
||||||
|
// in setSport) so React batches both pieces of state correctly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(sport === 'Soccer' ? { sport, league } : { sport });
|
||||||
|
}
|
||||||
|
}, [sport, league, onChange]);
|
||||||
|
|
||||||
|
function selectSport(next: Sport) {
|
||||||
|
setSport(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="sport-selector" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Sport"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SPORTS.map((s) => {
|
||||||
|
const active = sport === s.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => selectSport(s.id)}
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
border: active ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||||
|
background: active ? 'var(--grade-a)' : 'transparent',
|
||||||
|
color: active ? 'var(--bg-primary)' : 'var(--text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
{s.status === 'beta' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 9,
|
||||||
|
padding: '1px 4px',
|
||||||
|
background: active ? 'var(--bg-primary)' : 'var(--grade-a)',
|
||||||
|
color: active ? 'var(--grade-a)' : 'var(--bg-primary)',
|
||||||
|
borderRadius: 3,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sport === 'Soccer' && (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Soccer league"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
padding: '8px 0 4px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SOCCER_LEAGUES.map((l) => {
|
||||||
|
const active = league === l.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => setLeague(l.id)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: active ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||||
|
background: active ? 'var(--bg-elevated)' : 'transparent',
|
||||||
|
color: active ? 'var(--grade-a)' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{l.label}</span>
|
||||||
|
{l.sub && (
|
||||||
|
<span className="mono" style={{ fontSize: 10, opacity: 0.6 }}>
|
||||||
|
{l.sub}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user