Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)

This commit is contained in:
Kev
2026-06-10 19:41:37 -04:00
parent 4db1c1c539
commit b55dcbd614
25 changed files with 2463 additions and 22 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -10,6 +10,7 @@ import InstallPrompt from '@/components/InstallPrompt';
import PushPrompt from '@/components/PushPrompt';
import MFAPrompt from '@/components/MFAPrompt';
import MFAChallenge from '@/components/MFAChallenge';
import CookieConsent from '@/components/CookieConsent';
import './globals.css';
export const metadata: Metadata = {
@@ -19,7 +20,7 @@ export const metadata: Metadata = {
template: '%s · VYNDR',
},
description:
"Grade NBA, MLB, and WNBA props with intelligence the books don't want you to have. Built in Detroit.",
"Grade NBA, MLB, WNBA, and soccer props with intelligence the books don't want you to have. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.",
applicationName: 'VYNDR',
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
manifest: '/manifest.json',
@@ -28,6 +29,9 @@ export const metadata: Metadata = {
'NBA prop bet analysis',
'MLB prop intelligence',
'WNBA prop grading',
'soccer prop intelligence',
'World Cup 2026 props',
'xG regression analysis',
'parlay correlation analysis',
'prop betting tools',
],
@@ -104,6 +108,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<PushPrompt />
<MFAPrompt />
<MFAChallenge />
<CookieConsent />
</ParlayProvider>
</ExplainModeProvider>
</AuthProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import Pricing from '@/components/Pricing';
export const metadata: Metadata = {
title: 'Pricing — VYNDR',
description: 'Founder pricing locks for life. $14.99/mo Analyst, $44.99/mo Desk. First 100 seats only.',
openGraph: {
title: 'VYNDR — Founder Pricing',
description: 'Sports prop intelligence. Beta pricing locks for life. First 100 seats.',
type: 'website',
url: 'https://vyndr.app/pricing',
},
twitter: {
card: 'summary_large_image',
title: 'VYNDR — Founder Pricing',
description: 'Sports prop intelligence. Beta pricing locks for life.',
},
};
/**
* Dedicated /pricing route. The Pricing component is also embedded on
* the landing page under the `#pricing` anchor; this page exists so:
* - The renewal email at `web/src/services/email.ts` (which links
* to `/pricing`) lands on something real instead of 404'ing.
* - Nav / CTA links can hand users a single stable URL whether
* they're already authenticated or not.
* - SEO crawlers see pricing on a canonical URL, not deep in the
* landing-page anchor.
*
* The component itself is fully client-side rendered (it owns
* checkout state + AuthContext access), so this page wraps it in a
* minimal scroll-restoration-friendly shell.
*/
export default function PricingPage() {
return (
<main style={{ minHeight: '100vh', paddingTop: 64 }}>
<Pricing />
</main>
);
}
+11 -1
View File
@@ -18,7 +18,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
body: [
'Account data: email, password hash, age confirmation, signup timestamp.',
'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.',
'Payment data: NexaPay processes all card data — we never see or store your card. We retain a NexaPay customer ID and your subscription status.',
'Payment data: Stripe processes all card data — we never see or store your card number, CVC, or any other payment instrument detail. We retain a Stripe customer ID, Stripe subscription ID, and your subscription status (active, canceled, grace period, expired) so we can gate paid features and respond to renewal/cancellation events from Stripe webhooks.',
'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).',
],
},
@@ -54,6 +54,16 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.',
],
},
{
title: 'Sub-processors',
body: [
'We rely on a small set of third-party processors. They handle data on our behalf under their own privacy commitments; we do not give them permission to use your data for their own purposes.',
'Stripe — payment processing and subscription management. Receives: name (if you provide), email, billing address (if you provide), and the card details you enter on their hosted checkout page. We never see your card number.',
'Supabase — authentication, database, and file storage. Receives: everything in the "Data we collect" section above. Supabase is our primary backend.',
'PostHog — product analytics. Receives: anonymized event data (page views, button clicks). IPs are anonymized before storage.',
'Resend — transactional email (account confirmations, payment receipts, renewal reminders). Receives: your email address and the message contents.',
],
},
{
title: 'Notifications',
body: [
+2 -1
View File
@@ -29,7 +29,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
{
title: 'Subscription terms',
body: [
'Paid tiers (Analyst, Desk) are billed monthly through NexaPay. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months.',
'Paid tiers (Analyst, Desk) are billed monthly through Stripe, our payment processor. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months. If a payment fails, we honor a 48-hour grace period before reverting your account to the free tier.',
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
'We may change regular pricing with 30 days notice. Founder pricing is locked.',
],
@@ -38,6 +38,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
title: 'Acceptable use',
body: [
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.',
'VYNDR does NOT offer API access at any tier. The grading engine is consumer-only — we do not provide programmatic access to grades, factor weights, model outputs, or any other engine surface, regardless of plan or pricing. Requests for API access will be declined.',
],
},
{
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
const STORAGE_KEY = 'vyndr_cookie_consent';
/**
* Cookie consent — thin bottom bar shown on first visit. Single line,
* dark, dismissable. "Accept" writes a flag to localStorage so the
* banner never appears again on this device.
*
* SSR-safe: we render nothing until the client mounts and the
* localStorage check completes. That prevents a hydration mismatch
* (server has no `window.localStorage`, so it can't know the user's
* prior choice) and avoids the brief banner flash on every refresh
* for users who already accepted.
*
* GDPR posture: VYNDR's cookies are essential (auth + read counter)
* plus analytics (anonymized PostHog). We disclose; we don't pre-tick
* checkboxes for non-essential analytics. The "Accept" button only
* acknowledges that you saw the disclosure.
*/
export default function CookieConsent() {
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
if (window.localStorage.getItem(STORAGE_KEY) !== 'accepted') {
setVisible(true);
}
} catch {
// Storage may be unavailable in private mode — fail closed: show
// the banner. Cheaper than tracking sessions for these users.
setVisible(true);
}
}, []);
function accept() {
try {
window.localStorage.setItem(STORAGE_KEY, 'accepted');
} catch {
/* private mode — banner will reappear next visit; acceptable. */
}
setVisible(false);
}
if (!visible) return null;
return (
<div
role="region"
aria-label="Cookie notice"
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
zIndex: 60,
background: 'rgba(10, 10, 15, 0.96)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderTop: '1px solid var(--border)',
padding: '12px 16px',
}}
>
<div
style={{
maxWidth: 1100,
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
fontSize: 13,
color: 'var(--text-secondary)',
}}
>
<span>
We use cookies for authentication and anonymized analytics.{' '}
<Link
href="/privacy"
style={{ color: 'var(--grade-a)', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
Privacy policy
</Link>
.
</span>
<button
type="button"
onClick={accept}
className="btn-primary"
style={{ padding: '6px 14px', fontSize: 12 }}
>
Accept
</button>
</div>
</div>
);
}