Session 41: P0 audit fixes — MLB stat_types, broken routes, tier mismatch, self-hosted fonts (1940 tests)

- Backend: whitelist MLB stat_types in analyze.js + scan.js gates (mirrors
  python validation.py); fixes MLB scans 400ing.
- Routes: /settings -> /profile, /report -> /blog redirect pages.
- Profile: read tier from useAuth().tier (nav's source) to kill the
  Free-vs-DESK mismatch.
- Fonts: self-host Inter/JetBrains Mono/IBM Plex Mono via next/font, drop the
  503ing fonts.googleapis.com <link>; rewire literal font-family refs to vars.
- Kept /settings/security (real MFA page) intact — NOT clobbered to a redirect.
- +33 tests (1907 -> 1940), 149 suites; web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-17 21:55:40 -04:00
parent e453c24d2c
commit 32069863dc
17 changed files with 318 additions and 41 deletions
+47 -3
View File
@@ -1,11 +1,55 @@
# VYNDR — Build State # VYNDR — Build State
## Last Updated ## Last Updated
2026-06-16 2026-06-17
## Current Phase ## Current Phase
SHIP BUILD v39.0 — VYNDR 2.0 design system, Phase H: QA pass — §13 parity SHIP BUILD v41.0 — P0 audit fixes (MLB stat gate, broken routes, profile tier,
verified, conversion COMPLETE (Session 39) self-hosted fonts). Design conversion (3339) remains COMPLETE.
## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES
The Chrome audit's P0 list only. No features. Backend 1907 → **1940 tests**
(+33), 149 suites, all green. Web build clean (compiled successfully, exit 0).
### Fixes
1. **MLB stat_type gate (BACKEND)** — the Scan frontend sends MLB IDs (`hits`,
`strikeouts`, `total_bases`, `rbi`, `home_runs`, `earned_runs`, `hits_allowed`,
`innings_pitched`, `runs`, `walks`, `stolen_bases`) but `VALID_STAT_TYPES` in
`src/routes/analyze.js` + `src/routes/scan.js` only whitelisted the NBA/soccer
set, so every MLB scan 400'd. Added the MLB set to both gates, mirroring the
already-correct `python/utils/validation.py` `VALID_STAT_TYPES.mlb`. The live
grade path is the generic engine1 feature pipeline (note: `mlbGrader.js` is
dead code — required nowhere), which keys off these exact stat names.
2. **/settings → /profile, /report → /blog** — new server-component `redirect()`
pages (`app/settings/page.tsx`, `app/report/page.tsx`). `/profile` is the
canonical account surface; `/blog` is where "The Report" lives.
3. **Profile tier mismatch**`app/profile/page.tsx` showed "Free" (from the
`/api/user/profile` fetch) while the nav showed the real tier (from
`useAuth().tier`). Profile now derives the displayed tier from
`useAuth().tier` (`authTier || profile.tier || 'free'`); the fetch still
supplies scan_count/subscription_*/founder.
4. **Self-hosted fonts** — replaced the runtime `fonts.googleapis.com`
stylesheet `<link>` (503 in prod, ~4s page load) with `next/font/google`:
Inter→`--font-sans`, JetBrains_Mono→`--font-mono`, IBM_Plex_Mono→`--font-ibm`,
variables set on `<html>`. globals.css `:root` maps `--sans`/`--mono`/
`--ibm-mono` onto them. Because next/font obfuscates family names, literal
`'JetBrains Mono'`/`'IBM Plex Mono'` references in CSS + inline component
styles were rewired to the variables (Hero, upgrade/desk, NotificationBell,
responsible-gambling, globals.css wordmark/.lines).
### Deviations from the spec (flagged)
- **Did NOT overwrite `/settings/security`.** The spec said redirect it to
`/profile` too, but that route is a working MFA enrollment flow — clobbering it
would be a security-feature regression. Left intact; only the bare `/settings`
404 was fixed.
- **`/report` link doesn't exist in current code** — Nav's "The Report" already
points to `/blog`. The redirect is harmless defensive coverage (PWA shortcut /
external / stale share links).
- **ShareCard canvas** still uses literal `"JetBrains Mono"`/`"Instrument Sans"`
(canvas `ctx.font` can't read CSS vars). Out of P0 scope; pre-existing.
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE ## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
+28
View File
@@ -293,6 +293,34 @@ The 7-session design conversion (3339) is done and parity-verified against §
it falls through to live adapters on cache miss and flaked at Jest's 5s default it falls through to live adapters on cache miss and flaked at Jest's 5s default
under full-suite load (same family as the S32 pipeline test). under full-suite load (same family as the S32 pipeline test).
## P0 Audit Fixes (Session 41 — non-obvious)
- **THREE stat_type whitelists must stay in sync.** A prop's `stat_type` is
gated in `src/routes/analyze.js` (`/prop` + `/batch`), `src/routes/scan.js`
(parlay legs), AND `src/services/python/utils/validation.py`. Adding a sport's
stats to one without the others silently 400s. The S41 MLB bug was exactly
this: Python had the MLB set; both Node gates didn't. `mlbGrader.js` is DEAD
CODE (required nowhere) — the live MLB grade path is the generic engine1
feature pipeline, which keys off the Python validator's exact stat names
(`rbi`/`runs`/`innings_pitched`, NOT `rbis`/`runs_scored`/`outs_recorded`).
- **`tests/integration/analyze.test.js` sits exactly at the 10-req/min IP rate
limit** (`createRateLimit max:10`, no reset hook). Adding any HTTP test there
429s the later cases. Put new analyze-route HTTP tests in a SEPARATE file
(jest isolates module state per file → fresh limiter): see
`tests/integration/analyzeMlbStats.test.js`.
- **Fonts are self-hosted via `next/font/google`** (layout.tsx): Inter→
`--font-sans`, JetBrains_Mono→`--font-mono`, IBM_Plex_Mono→`--font-ibm`, all
set on `<html className>`. globals.css `:root` maps `--sans`/`--mono`/
`--ibm-mono` onto them. CRITICAL: next/font OBFUSCATES family names, so literal
`'JetBrains Mono'`/`'IBM Plex Mono'` in CSS or inline `fontFamily` no longer
resolve — always reference the variable. The old `fonts.googleapis.com` CDN
`<link>` is GONE (it 503'd in prod). ShareCard canvas still uses literal names
(canvas can't read CSS vars) — knowingly left.
- **Redirect routes** use server-component `redirect()` from `next/navigation`
(`/settings`→`/profile`, `/report`→`/blog`). `/settings/security` is a REAL
MFA enrollment page — do NOT clobber it into a redirect.
- **Profile tier** reads from `useAuth().tier` (the nav's source), not the
`/api/user/profile` fetch, so the two can't disagree.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+6
View File
@@ -55,6 +55,12 @@ const VALID_STAT_TYPES = new Set([
// discriminates downstream). // discriminates downstream).
'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
// MLB (Session 41) — mirrors src/services/python/utils/validation.py
// VALID_STAT_TYPES.mlb. The Scan frontend sends these IDs; the grading
// engine already processes them — this gate was the only thing rejecting.
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
'walks', 'runs', 'earned_runs', 'innings_pitched',
'hits_allowed', 'stolen_bases',
]); ]);
const VALID_DIRECTIONS = new Set(['over', 'under']); const VALID_DIRECTIONS = new Set(['over', 'under']);
+4
View File
@@ -13,6 +13,10 @@ const VALID_STAT_TYPES = new Set([
// Soccer (Session 7j) // Soccer (Session 7j)
'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
// MLB (Session 41) — mirrors src/routes/analyze.js + python validation.
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
'walks', 'runs', 'earned_runs', 'innings_pitched',
'hits_allowed', 'stolen_bases',
]); ]);
const VALID_DIRECTIONS = new Set(['over', 'under']); const VALID_DIRECTIONS = new Set(['over', 'under']);
+70
View File
@@ -0,0 +1,70 @@
// Session 41 — MLB stat_type acceptance. The Scan frontend sends MLB IDs
// (hits, strikeouts, total_bases, rbi, home_runs, earned_runs, hits_allowed,
// innings_pitched) but the /api/analyze validation gate only whitelisted the
// NBA/soccer set, so every MLB scan 400'd. This file lives apart from
// analyze.test.js because that suite sits exactly at the 10/min IP rate-limit
// boundary; a separate file gets a fresh limiter (jest isolates module state
// per file) so we can exercise all eight MLB stats under the cap.
const request = require('supertest');
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
cacheGet: async () => null,
cacheSet: async () => true,
cacheDel: async () => true,
isDegraded: () => false,
}));
const mockAnalyzeViaEngine1 = jest.fn();
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
}));
jest.mock('../../src/utils/tierGating', () => ({ applyTierGating: (result) => result }));
const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit');
const app = require('../../src/app');
const MLB_STATS = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
beforeEach(() => {
jest.clearAllMocks();
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockAnalyzeViaEngine1.mockReset();
if (scanLimitInternals && scanLimitInternals.resetForTests) scanLimitInternals.resetForTests();
});
describe('POST /api/analyze/prop — MLB stat_types', () => {
it.each(MLB_STATS)('accepts %s (no longer 400s at the validation gate)', async (stat) => {
mockAnalyzeViaEngine1.mockResolvedValueOnce({
player: 'Aaron Judge',
stat_type: stat,
line: 1.5,
direction: 'over',
grade: 'B',
confidence: 70,
kill_conditions_triggered: [],
reasoning: { summary: 'ok', steps: [] },
});
const res = await request(app)
.post('/api/analyze/prop')
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: stat, line: 1.5, direction: 'over' });
expect(res.status).toBe(200);
expect(res.body.error).toBeUndefined();
expect(mockAnalyzeViaEngine1).toHaveBeenCalled();
});
it('still rejects a genuinely invalid stat_type', async () => {
const res = await request(app)
.post('/api/analyze/prop')
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: 'vibes', line: 1.5, direction: 'over' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid stat_type');
});
});
+80
View File
@@ -0,0 +1,80 @@
// Session 41 — P0 audit fixes. Frontend assertions read page source as text
// (same pattern as the Phase DH suites); backend stat-gate acceptance is
// covered in tests/integration/analyze.test.js.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('Session 41 — backend MLB stat_type whitelist', () => {
const analyze = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'analyze.js'), 'utf8');
const scan = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'scan.js'), 'utf8');
const mlbStats = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
it.each(mlbStats)('/api/analyze gate whitelists MLB stat %s', (stat) => {
expect(analyze).toContain(`'${stat}'`);
});
it.each(mlbStats)('/api/scan (parlay) gate whitelists MLB stat %s', (stat) => {
expect(scan).toContain(`'${stat}'`);
});
});
describe('Session 41 — broken-route redirects', () => {
it('/settings redirects to /profile', () => {
const src = read('app/settings/page.tsx');
expect(src).toContain("from 'next/navigation'");
expect(src).toContain("redirect('/profile')");
});
it('/report redirects to /blog (THE REPORT link target)', () => {
const src = read('app/report/page.tsx');
expect(src).toContain("redirect('/blog')");
});
it('/settings/security stays the real MFA page (NOT clobbered into a redirect)', () => {
// The audit spec wanted this redirected too, but it is a working MFA
// enrollment flow — overwriting it would be a security-feature regression.
const src = read('app/settings/security/page.tsx');
expect(src).toContain('mfa');
expect(src).not.toContain("redirect('/profile')");
});
});
describe('Session 41 — profile reads tier from useAuth', () => {
const src = read('app/profile/page.tsx');
it('destructures tier from useAuth (same source as the nav)', () => {
expect(src).toMatch(/tier:\s*authTier/);
});
it('derives the displayed tier from the auth session', () => {
expect(src).toContain('authTier || profile.tier');
});
});
describe('Session 41 — self-hosted fonts (no Google Fonts CDN)', () => {
const layout = read('app/layout.tsx');
const globals = read('app/globals.css');
it('layout uses next/font instead of a runtime <link>', () => {
expect(layout).toContain("from 'next/font/google'");
expect(layout).toContain('Inter(');
expect(layout).toContain('JetBrains_Mono(');
});
it('removed the runtime Google Fonts stylesheet <link>', () => {
// The historical reference survives in a code comment; what must be gone
// is the actual CDN stylesheet href that caused the 503.
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
expect(layout).not.toContain('rel="stylesheet"');
});
it('globals.css :root maps --sans/--mono onto the next/font variables', () => {
expect(globals).toContain('--sans: var(--font-sans)');
expect(globals).toContain('--mono: var(--font-mono)');
});
});
+10 -5
View File
@@ -37,8 +37,11 @@ describe('Phase A — design tokens (§2)', () => {
}); });
it('sets --sans to Inter and --mono to JetBrains Mono', () => { it('sets --sans to Inter and --mono to JetBrains Mono', () => {
expect(css).toMatch(/--sans:\s*'Inter'/); // Session 41 — fonts are self-hosted via next/font, so the families now
expect(css).toMatch(/--mono:\s*'JetBrains Mono'/); // come through the --font-* variables (with the literal names kept as
// fallbacks for any non-next/font context).
expect(css).toMatch(/--sans:\s*var\(--font-sans\),\s*'Inter'/);
expect(css).toMatch(/--mono:\s*var\(--font-mono\),\s*'JetBrains Mono'/);
}); });
it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => { it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => {
@@ -48,9 +51,11 @@ describe('Phase A — design tokens (§2)', () => {
}); });
describe('Phase A — fonts (§2)', () => { describe('Phase A — fonts (§2)', () => {
it('loads Inter (400900) and JetBrains Mono (400800) from Google Fonts', () => { it('self-hosts Inter + JetBrains Mono via next/font (Session 41 — no CDN <link>)', () => {
expect(layout).toContain('Inter:wght@400;500;600;700;800;900'); expect(layout).toContain("from 'next/font/google'");
expect(layout).toContain('JetBrains+Mono:wght@400;500;600;700;800'); expect(layout).toContain('Inter(');
expect(layout).toContain('JetBrains_Mono(');
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
}); });
}); });
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -94,9 +94,12 @@
--scan-op: 0.04; --scan-op: 0.04;
--grade-hero: var(--g-a); --grade-hero: var(--g-a);
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data */ /* Type — Inter for chrome/UI, JetBrains Mono for ALL data.
--sans: 'Inter', system-ui, sans-serif; Session 41: --font-* come from next/font (self-hosted, set on <html>).
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace; Literal family names kept as fallbacks for any non-next/font context. */
--sans: var(--font-sans), 'Inter', system-ui, sans-serif;
--mono: var(--font-mono), 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
--ibm-mono: var(--font-ibm), var(--font-mono), 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
/* ── Legacy aliases — every existing component still resolves ── */ /* ── Legacy aliases — every existing component still resolves ── */
--bg-primary: var(--bg-0); --bg-primary: var(--bg-0);
@@ -605,7 +608,7 @@ body.tex-grain::before {
───────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────── */
.wordmark { .wordmark {
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace; font-family: var(--ibm-mono);
font-weight: 800; font-weight: 800;
letter-spacing: 0.10em; letter-spacing: 0.10em;
display: inline-flex; display: inline-flex;
@@ -717,7 +720,7 @@ body.tex-grain::before {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 10px 4px 4px; padding: 4px 10px 4px 4px;
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace; font-family: var(--ibm-mono);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.15em; letter-spacing: 0.15em;
@@ -743,7 +746,7 @@ body.tex-grain::before {
padding: 6px 10px; padding: 6px 10px;
background: var(--bg-1); background: var(--bg-1);
border: 1px solid var(--border); border: 1px solid var(--border);
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace; font-family: var(--ibm-mono);
font-size: 10px; font-size: 10px;
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
@@ -761,7 +764,7 @@ body.tex-grain::before {
/* The V watermark — Vendetta nod, hero background */ /* The V watermark — Vendetta nod, hero background */
.v-watermark { .v-watermark {
position: absolute; position: absolute;
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace; font-family: var(--ibm-mono);
font-weight: 800; font-weight: 800;
font-size: 70vmin; font-size: 70vmin;
line-height: 0.8; line-height: 0.8;
+18 -11
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter, JetBrains_Mono, IBM_Plex_Mono } from 'next/font/google';
import PostHogProvider from '@/components/PostHogProvider'; import PostHogProvider from '@/components/PostHogProvider';
import AuthProvider from '@/contexts/AuthContext'; import AuthProvider from '@/contexts/AuthContext';
import ParlayProvider from '@/contexts/ParlayContext'; import ParlayProvider from '@/contexts/ParlayContext';
@@ -21,6 +22,22 @@ import { headers } from 'next/headers';
import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales'; import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
import './globals.css'; import './globals.css';
// Session 41 — self-hosted via next/font (downloaded at build time, served
// from our origin). Replaces the runtime fonts.googleapis.com <link>, which
// returned 503 in production and added ~4s to page load. §2 brand fonts:
// Inter for chrome/UI (--font-sans), JetBrains Mono for ALL data (--font-mono).
// IBM Plex Mono (--font-ibm) kept for the wordmark + legacy mono surfaces while
// pages migrate. globals.css :root maps --sans/--mono onto these variables.
const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' });
const ibmPlexMono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-ibm',
display: 'swap',
});
const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`;
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'), metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
title: { title: {
@@ -110,17 +127,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const country = hdrs.get(COUNTRY_HEADER) || ''; const country = hdrs.get(COUNTRY_HEADER) || '';
return ( return (
<html lang={locale} dir={dir} className="dark"> <html lang={locale} dir={dir} className={`dark ${fontVars}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* VYNDR 2.0 (§2): Inter for chrome/UI, JetBrains Mono for ALL data.
IBM Plex Mono + Instrument Sans kept while pages migrate session-by-session. */}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="antialiased tex-grain"> <body className="antialiased tex-grain">
<LocaleProvider locale={locale} country={country}> <LocaleProvider locale={locale} country={country}>
<PostHogProvider> <PostHogProvider>
+14 -8
View File
@@ -18,7 +18,7 @@ interface FullProfile {
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const { user, signOut, loading: authLoading } = useAuth(); const { user, tier: authTier, signOut, loading: authLoading } = useAuth();
const [profile, setProfile] = useState<FullProfile | null>(null); const [profile, setProfile] = useState<FullProfile | null>(null);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -69,6 +69,12 @@ export default function ProfilePage() {
); );
} }
// Session 41 — tier is read from useAuth (the same live-session source the
// nav uses) so the two can't disagree. The /api/user/profile fetch can lag
// or return a stale/null tier; the auth context is authoritative. Profile
// still supplies the other fields (scan_count, subscription_*, founder).
const tier = authTier || profile.tier || 'free';
return ( return (
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}> <section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
<header style={{ marginBottom: 24 }}> <header style={{ marginBottom: 24 }}>
@@ -88,8 +94,8 @@ export default function ProfilePage() {
{/* Session 27 — always render a tier label. When the profile {/* Session 27 — always render a tier label. When the profile
API returns null/undefined tier (free users sometimes do), API returns null/undefined tier (free users sometimes do),
fall back to 'free' so the field is never blank. */} fall back to 'free' so the field is never blank. */}
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier || 'free') }}> <h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(tier) }}>
{profile.tier || 'free'} {tier}
{profile.founder_pricing && ( {profile.founder_pricing && (
<span <span
className="mono" className="mono"
@@ -108,27 +114,27 @@ export default function ProfilePage() {
)} )}
</h2> </h2>
</div> </div>
{profile.tier === 'free' && ( {tier === 'free' && (
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}> <a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
Upgrade Upgrade
</a> </a>
)} )}
</div> </div>
{profile.tier !== 'free' && ( {tier !== 'free' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} /> <Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} /> <Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
</div> </div>
)} )}
{profile.tier === 'free' && ( {tier === 'free' && (
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} /> <Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
)} )}
</section> </section>
{/* Founder pricing promo for free users */} {/* Founder pricing promo for free users */}
{profile.tier === 'free' && ( {tier === 'free' && (
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}> <section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}> <p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
FOUNDER ACCESS FOUNDER ACCESS
@@ -146,7 +152,7 @@ export default function ProfilePage() {
)} )}
{/* Subscription actions */} {/* Subscription actions */}
{profile.tier !== 'free' && !profile.cancel_at_period_end && ( {tier !== 'free' && !profile.cancel_at_period_end && (
<section className="surface" style={{ padding: 20, marginBottom: 16 }}> <section className="surface" style={{ padding: 20, marginBottom: 16 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3> <h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation';
/**
* /report (Session 41 — P0 audit fix).
*
* The MORE dropdown links "THE REPORT" to /report, but the blog lives at
* /blog. Forward there so the link resolves instead of 404ing.
*/
export default function ReportPage() {
redirect('/blog');
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function ResponsibleGamblingPage() {
<li> <li>
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong> <strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
<br /> <br />
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'JetBrains Mono, monospace' }}> <a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'var(--mono)' }}>
1-800-522-4700 1-800-522-4700
</a> </a>
<br /> <br />
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
/**
* /settings (Session 41 — P0 audit fix).
*
* The audit found /settings 404'd. Account/preferences already live on
* /profile (the canonical surface), so forward there rather than build a
* second screen that would drift out of sync. Server-side redirect — no
* flash of an empty page.
*/
export default function SettingsPage() {
redirect('/profile');
}
+2 -2
View File
@@ -149,7 +149,7 @@ export default function DeskUpgradePage() {
border: 'none', border: 'none',
background: cadence === c ? 'var(--bg-3)' : 'transparent', background: cadence === c ? 'var(--bg-3)' : 'transparent',
color: cadence === c ? 'var(--text-0)' : 'var(--text-1)', color: cadence === c ? 'var(--text-0)' : 'var(--text-1)',
fontFamily: 'IBM Plex Mono, monospace', fontFamily: 'var(--ibm-mono)',
fontSize: 12, fontSize: 12,
fontWeight: 700, fontWeight: 700,
letterSpacing: '0.08em', letterSpacing: '0.08em',
@@ -241,7 +241,7 @@ function TierCard({
<ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}> <ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}>
{features.map((f) => ( {features.map((f) => (
<li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}> <li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}>
<span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'IBM Plex Mono, monospace' }}></span> <span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'var(--ibm-mono)' }}></span>
<span>{f}</span> <span>{f}</span>
</li> </li>
))} ))}
+1 -1
View File
@@ -133,7 +133,7 @@ function SportBadgeStrip() {
> >
{SPORTS_DISPLAY.map((s) => { {SPORTS_DISPLAY.map((s) => {
const base: React.CSSProperties = { const base: React.CSSProperties = {
fontFamily: 'IBM Plex Mono, JetBrains Mono, monospace', fontFamily: 'var(--ibm-mono)',
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
letterSpacing: '0.08em', letterSpacing: '0.08em',
+2 -2
View File
@@ -173,7 +173,7 @@ export default function NotificationBell() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)', boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)',
fontFamily: 'IBM Plex Mono, monospace', fontFamily: 'var(--ibm-mono)',
}} }}
> >
{unread > 9 ? '9+' : unread} {unread > 9 ? '9+' : unread}
@@ -236,7 +236,7 @@ export default function NotificationBell() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span <span
style={{ style={{
fontFamily: 'IBM Plex Mono, monospace', fontFamily: 'var(--ibm-mono)',
fontSize: 10, fontSize: 10,
letterSpacing: '0.08em', letterSpacing: '0.08em',
color: TYPE_TINT[n.type], color: TYPE_TINT[n.type],