Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge
This commit is contained in:
+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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user