Files
vyndr/web/src/components/Pricing.tsx
T

350 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useT, useRegion } from '@/contexts/LocaleContext';
type TierId = 'free' | 'africa' | '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',
price: '$0',
cadence: '/mo',
headline: 'Try the model. No card required.',
cta: 'Start Free',
features: [
'3 reads per day',
'Grade letter + projection',
'Cross-book line comparison',
'Confidence indicator',
],
locked: [
'Factor analysis (blurred)',
'Kill conditions (blurred)',
'Alt line ladder (locked)',
],
highlight: false,
},
// Session 12 — VYNDR Africa tier ($4.99/mo). Slotted between Free
// and Analyst. Pricing component reorders dynamically based on
// locale (African-language users see this first).
{
id: 'africa',
name: 'VYNDR Africa',
price: '$4.99',
cadence: '/mo',
headline: 'Built for African mobile bettors.',
cta: 'Unlock Africa Pricing',
features: [
'10 reads per day',
'Full factor analysis (40+ signals)',
'Kill conditions surfaced inline',
'Grade + reasoning visible',
'World Cup soccer intelligence',
],
locked: [
'Cascade alerts (Analyst+)',
'Alt line ladder (Desk only)',
],
highlight: false,
},
{
id: 'analyst',
name: 'Analyst',
price: '$14.99',
originalPrice: '$24.99',
cadence: '/mo',
badge: 'Founder Access',
headline: 'The full intelligence layer.',
cta: 'Lock Founder Price',
features: [
'Unlimited reads',
'Full factor analysis (40+ signals)',
'Kill conditions surfaced inline',
'Cascade alerts when lineups shift',
'Parlay leg history with grades',
'Sportsbook deep links',
],
locked: [
'Alt line ladder (Desk only)',
'Kelly sizing (Desk only)',
],
highlight: true,
},
{
id: 'desk',
name: 'Desk',
price: '$44.99',
originalPrice: '$49.99',
cadence: '/mo',
headline: 'Everything. The professional setup.',
cta: 'Go Desk',
features: [
'Everything in Analyst',
'Alt line ladder + edge ranking',
'Quarter-Kelly sizing recommendations',
'Real-time intelligence feed',
'Parlay correlation analysis (phi)',
'Consensus vs model comparison',
],
locked: [],
highlight: false,
},
];
export default function Pricing() {
const router = useRouter();
const { session, loading: authLoading } = useAuth();
const { inAfrica } = useRegion();
const t = useT();
const [pending, setPending] = useState<TierId | null>(null);
const [error, setError] = useState<string | null>(null);
// Session 13 — Africa tier visibility + order is now driven by
// REAL IP geolocation via Cloudflare's CF-IPCountry header (stamped
// onto x-vyndr-country by the middleware). The previous locale-
// based proxy (Swahili speakers everywhere) was both too narrow
// (most African users browse in English/French) and too broad
// (Swahili speakers outside Africa got the discount).
//
// Inside Africa: VYNDR Africa renders first, then Free, then Analyst, Desk.
// Outside Africa: the Africa tier card is filtered out of the render
// entirely — no path for non-African users to even
// see the $4.99 option.
// Unknown country (local dev, non-Cloudflare): degrades closed →
// Africa tier hidden (same as outside Africa).
const orderedTiers = inAfrica
? [TIERS.find((x) => x.id === 'africa')!, TIERS.find((x) => x.id === 'free')!,
TIERS.find((x) => x.id === 'analyst')!, TIERS.find((x) => x.id === 'desk')!]
: TIERS.filter((x) => x.id !== 'africa');
async function startCheckout(tier: TierId) {
setError(null);
// Free tier short-circuits — no checkout, just signup.
if (tier === 'free') {
router.push('/signup');
return;
}
// Session 15 — Africa short-circuit removed. The Session 14
// backend now handles 'africa' end-to-end: validation accepts
// it, and when STRIPE_PRICE_AFRICA isn't configured the route
// returns 503 { code: 'tier_unconfigured', error: '...' } which
// the existing error-display path below surfaces inline. No
// special-case needed at the UI layer.
// 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"
style={{
padding: '96px 24px',
borderTop: '1px solid var(--border)',
}}
>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
<h2
className="text-balance"
style={{ fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 16 }}
>
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. 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,
// The desktop column count tracks the visible tier count
// (3 outside Africa, 4 inside). styled-jsx's `:global()`
// doesn't handle attribute selectors cleanly, so we pin
// the value via a CSS custom property on the grid root.
['--pricing-cols' as keyof React.CSSProperties]: String(orderedTiers.length),
} as React.CSSProperties}
>
{orderedTiers.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={{
width: '100%',
padding: 14,
marginBottom: 24,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
}}
>
{isPending ? 'Redirecting to Stripe…' : tier.cta}
</button>
<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 / Apple Pay / Google Pay payments processed by Stripe. First 100 users lock $14.99/mo Analyst for life.
</p>
</div>
<style jsx>{`
:global(.pricing-grid) {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
:global(.pricing-grid) {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1100px) {
:global(.pricing-grid) {
/* --pricing-cols is set by the React render (3 outside
Africa, 4 inside) so the desktop layout tracks the
visible tier count without an attribute selector. */
grid-template-columns: repeat(var(--pricing-cols, 3), 1fr);
}
}
`}</style>
</section>
);
}