Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge
This commit is contained in:
@@ -47,10 +47,33 @@ export default function Nav() {
|
||||
>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
||||
|
||||
+168
-77
@@ -1,6 +1,26 @@
|
||||
'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',
|
||||
name: 'Free',
|
||||
@@ -8,7 +28,6 @@ const TIERS = [
|
||||
cadence: '/mo',
|
||||
headline: 'Try the model. No card required.',
|
||||
cta: 'Start Free',
|
||||
ctaHref: '/signup',
|
||||
features: [
|
||||
'5 reads per month',
|
||||
'Grade letter + projection',
|
||||
@@ -31,7 +50,6 @@ const TIERS = [
|
||||
badge: 'Founder Access',
|
||||
headline: 'The full intelligence layer.',
|
||||
cta: 'Lock Founder Price',
|
||||
ctaHref: '/api/checkout?tier=analyst',
|
||||
features: [
|
||||
'Unlimited reads',
|
||||
'Full factor analysis (40+ signals)',
|
||||
@@ -54,7 +72,6 @@ const TIERS = [
|
||||
cadence: '/mo',
|
||||
headline: 'Everything. The professional setup.',
|
||||
cta: 'Go Desk',
|
||||
ctaHref: '/api/checkout?tier=desk',
|
||||
features: [
|
||||
'Everything in Analyst',
|
||||
'Alt line ladder + edge ranking',
|
||||
@@ -62,7 +79,6 @@ const TIERS = [
|
||||
'Real-time intelligence feed',
|
||||
'Parlay correlation analysis (phi)',
|
||||
'Consensus vs model comparison',
|
||||
'API access (coming Q3)',
|
||||
],
|
||||
locked: [],
|
||||
highlight: false,
|
||||
@@ -70,6 +86,51 @@ const TIERS = [
|
||||
];
|
||||
|
||||
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 (
|
||||
<section
|
||||
id="pricing"
|
||||
@@ -87,89 +148,119 @@ export default function Pricing() {
|
||||
Pricing built for bettors. Not for SaaS investors.
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
|
||||
First 100 users lock $14.99/mo for life. This price dies at user 101.
|
||||
First 100 users lock $14.99/mo for life. Beta pricing — this price dies at user 101.
|
||||
</p>
|
||||
</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 }}>
|
||||
{TIERS.map((tier, i) => (
|
||||
<article
|
||||
key={tier.id}
|
||||
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
|
||||
style={{
|
||||
padding: 32,
|
||||
position: 'relative',
|
||||
border: tier.highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
background: tier.highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
boxShadow: tier.highlight ? '0 16px 48px var(--accent-glow)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tier.badge && (
|
||||
<div
|
||||
className="mono"
|
||||
{TIERS.map((tier, i) => {
|
||||
const isPending = pending === tier.id;
|
||||
const isDisabled = authLoading || (pending !== null && !isPending);
|
||||
return (
|
||||
<article
|
||||
key={tier.id}
|
||||
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
|
||||
style={{
|
||||
padding: 32,
|
||||
position: 'relative',
|
||||
border: tier.highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
background: tier.highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
boxShadow: tier.highlight ? '0 16px 48px var(--accent-glow)' : 'none',
|
||||
}}
|
||||
>
|
||||
{tier.badge && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: 24,
|
||||
padding: '4px 12px',
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-primary)',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.08em',
|
||||
borderRadius: 999,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 40, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.03em' }}>
|
||||
{tier.price}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 14 }}>{tier.cadence}</span>
|
||||
{tier.originalPrice && (
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||||
{tier.originalPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, minHeight: 42 }}>
|
||||
{tier.headline}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCheckout(tier.id)}
|
||||
disabled={isDisabled}
|
||||
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: 24,
|
||||
padding: '4px 12px',
|
||||
background: 'var(--grade-a)',
|
||||
color: 'var(--bg-primary)',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.08em',
|
||||
borderRadius: 999,
|
||||
textTransform: 'uppercase',
|
||||
width: '100%',
|
||||
padding: 14,
|
||||
marginBottom: 24,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 40, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.03em' }}>
|
||||
{tier.price}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 14 }}>{tier.cadence}</span>
|
||||
{tier.originalPrice && (
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||||
{tier.originalPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, minHeight: 42 }}>
|
||||
{tier.headline}
|
||||
</p>
|
||||
{isPending ? 'Redirecting to Stripe…' : tier.cta}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={tier.ctaHref}
|
||||
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ width: '100%', padding: 14, marginBottom: 24 }}
|
||||
>
|
||||
{tier.cta}
|
||||
</a>
|
||||
|
||||
<ul style={{ display: 'grid', gap: 10 }}>
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--grade-a)', fontWeight: 700 }} aria-hidden>+</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
{tier.locked.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14, color: 'var(--text-tertiary)' }}>
|
||||
<span aria-hidden>—</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
<ul style={{ display: 'grid', gap: 10 }}>
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--grade-a)', fontWeight: 700 }} aria-hidden>+</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
{tier.locked.map((f) => (
|
||||
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14, color: 'var(--text-tertiary)' }}>
|
||||
<span aria-hidden>—</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
||||
Cancel anytime. No contracts. Card or Apple Pay or Google Pay — payments processed by NexaPay.
|
||||
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe (test mode while we onboard founders).
|
||||
</p>
|
||||
</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