Files
vyndr/web/src/components/Pricing.tsx
T
builtbykev a18a3f33fc DS5 — Pricing (Desk-as-hero) + Motion + Empty/Error + archetype glyphs
Part 6 #8 — Desk $44.99 is the hero tier. New DeskShowcase leads the
pricing page with the "$1M terminal · $44.99" story + real feature
visuals (alt-line ladder, quarter-Kelly, parlay φ, real-time feed) in a
balanced two-column layout (kills the dead right-half). Desk is the sole
highlighted tier / single primary CTA (color contract #9); Analyst is
secondary. Real prices: Free 5 scans, Analyst $14.99/$19.99, Desk
$34.99/$44.99. ClaimMeter + Stripe checkout wiring untouched.

Part 4 #7 — Ticker → punctuated stillness. The continuous marquee is
retired; the ticker now RESTS ≥4s on each ranked item and pulses only on
change. The EKG heartbeat is a static readout — the header's ONE idle
proof-of-life is the single SIGNAL-LIVE live-dot (the ticker dropped its
competing pulse). All durations tokenized (--motion-*, --ticker-hold);
prefers-reduced-motion kills the motion entirely.

Part 8 #20 — new EmptyState component modeled on the north-star 404
(scanlines + glitch wordmark + amber system voice + CTA hierarchy),
reused at the bare-red "Team not found", "Game not found", and the
ledger empties — one unified voice.

Part 5 — archetype glyph+chip propagated to STREAKS rows + ledger rows
(optional + self-hiding; absent beats fabricated); grade reveal already
carries ArchetypeBlend.

Tests: new tests/unit/ds5PricingStates.test.js (22) locks all four
workstreams; updated teamHubUI + vyndrDesignSystem for the new surfaces.
237 suites / 2864 tests green; next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:31:35 -04:00

354 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: [
'5 scans to try the model',
'Grade letter + projection',
'Cross-book line comparison',
'Confidence indicator',
],
locked: [
'Factor analysis (3-signal preview)',
'Kill conditions (locked preview)',
'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 — every signal the model weighs',
'Kill conditions surfaced inline',
'Grade + reasoning visible',
'World Cup soccer intelligence',
],
locked: [
'Scratch cascade alerts (Analyst+)',
'Alt line ladder (Desk only)',
],
highlight: false,
},
{
id: 'analyst',
name: 'Analyst',
price: '$14.99',
originalPrice: '$19.99',
cadence: '/mo',
badge: 'Founder Access',
headline: 'The full intelligence layer.',
cta: 'Lock Founder Price',
features: [
'Unlimited reads',
'Full factor analysis — every signal the model weighs',
'Kill conditions surfaced inline',
'Scratch cascade alerts when players are pulled',
'Parlay leg history with grades',
'Sportsbook deep links',
],
locked: [
'Alt line ladder (Desk only)',
'Kelly sizing (Desk only)',
],
highlight: false,
},
{
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal"
// story and the single primary CTA on the grid (color contract #9: never
// two competing green CTAs). $44.99 regular, $34.99 for founders.
id: 'desk',
name: 'Desk',
price: '$34.99',
originalPrice: '$44.99',
cadence: '/mo',
badge: 'Founder Desk',
headline: 'The professional terminal. Everything the model knows.',
cta: 'Claim a Founder 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: true,
},
];
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>
);
}