350 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|