diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx
index 1331f13..37a82d8 100644
--- a/web/src/components/Pricing.tsx
+++ b/web/src/components/Pricing.tsx
@@ -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
(null);
+ const [error, setError] = useState(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 (
- 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.
+ {error && (
+
+ {error}
+
+ )}
+
- {TIERS.map((tier, i) => (
-
- {tier.badge && (
- {
+ const isPending = pending === tier.id;
+ const isDisabled = authLoading || (pending !== null && !isPending);
+ return (
+
+ {tier.badge && (
+
+ {tier.badge}
+
+ )}
+
+ {tier.name}
+
+
+
+ {tier.price}
+
+ {tier.cadence}
+ {tier.originalPrice && (
+
+ {tier.originalPrice}
+
+ )}
+
+
+ {tier.headline}
+
+
+
- )}
-
- {tier.name}
-
-
-
- {tier.price}
-
- {tier.cadence}
- {tier.originalPrice && (
-
- {tier.originalPrice}
-
- )}
-
-
- {tier.headline}
-
+ {isPending ? 'Redirecting to Stripe…' : tier.cta}
+
-
- {tier.cta}
-
-
-
- {tier.features.map((f) => (
- -
- +
- {f}
-
- ))}
- {tier.locked.map((f) => (
- -
- —
- {f}
-
- ))}
-
-
- ))}
+
+ {tier.features.map((f) => (
+ -
+ +
+ {f}
+
+ ))}
+ {tier.locked.map((f) => (
+ -
+ —
+ {f}
+
+ ))}
+
+
+ );
+ })}
- 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).
diff --git a/web/src/components/SoccerGradeResult.tsx b/web/src/components/SoccerGradeResult.tsx
new file mode 100644
index 0000000..68156ee
--- /dev/null
+++ b/web/src/components/SoccerGradeResult.tsx
@@ -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