Session 34: Design system Phase C — app shell, nav, routing, auth gate, footer, 404 (1818 tests)
VYNDR 2.0 conversion, Phase C (the frame every page sits inside). Frontend-only; zero backend changes. - Nav rewritten: new .wm Wordmark, mono uppercase links, More dropdown, search/ bell/read-meter/avatar, Ticker under the bar. layout main paddingTop 64 -> 96. - Routing: web/src/lib/routes.js (GATED/OPEN/HASH_ALIASES, isGatedRoute, resolveHashAlias). Client AuthGate bounces signed-out users off personal routes to /login?next=. HashRedirect maps #scan/#terminal to real routes. - Footer rewritten to system voice + Detroit signature; mounted globally in layout (removed per-page dup). - 404 converted to the north star (scanlines, crt-sweep, glitch wordmark, amber). - Stub pages for terminal/compare/invite/help/about/notifications via RouteStub. Honest reconciliations: auth gate is client-side (no auth-helpers pkg; session is client-side Supabase); GATED narrowed to protect the free-scan funnel; did not stub over existing real pages; redirect param is ?next= (what /login reads). 26 new tests. Backend 1792 -> 1818, 142 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+72
-129
@@ -1,151 +1,94 @@
|
||||
const PRIMARY_LINKS = [
|
||||
{ label: 'Read', href: '/scan' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'Ledger', href: '/ledger' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
import { Wordmark } from '@/components/vyndr';
|
||||
|
||||
// VYNDR 2.0 footer (§C.4). System language, not marketing — mono, uppercase
|
||||
// labels, the Detroit signature, and the legal/21+ line. Server component
|
||||
// (no interactivity); link hover is handled by the global .footer-link rule.
|
||||
const COLUMNS: { head: string; links: { label: string; href: string }[] }[] = [
|
||||
{
|
||||
head: 'PRODUCT',
|
||||
links: [
|
||||
{ label: 'Slate', href: '/dashboard' },
|
||||
{ label: 'The Terminal', href: '/terminal' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Invite friends', href: '/invite' },
|
||||
],
|
||||
},
|
||||
{
|
||||
head: 'COMPANY',
|
||||
links: [
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'The Report', href: '/blog' },
|
||||
{ label: 'Help & FAQ', href: '/help' },
|
||||
],
|
||||
},
|
||||
{
|
||||
head: 'LEGAL',
|
||||
links: [
|
||||
{ label: 'Responsible Play', href: '/responsible-gambling' },
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const LEGAL_LINKS = [
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
|
||||
// Session 17 — support contact in the legal column. Audit found
|
||||
// the platform had no support surface at all.
|
||||
{ label: 'Support', href: 'mailto:support@vyndr.app' },
|
||||
];
|
||||
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
const SOCIAL = [
|
||||
{ label: 'Twitter', href: 'https://twitter.com/getvyndr' },
|
||||
{ label: 'Discord', href: 'https://discord.gg/getvyndr' },
|
||||
];
|
||||
const mono = { fontFamily: 'var(--mono)' } as const;
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '64px 24px 32px',
|
||||
marginTop: 64,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 48,
|
||||
marginBottom: 48,
|
||||
}}
|
||||
className="footer-top"
|
||||
>
|
||||
<div style={{ maxWidth: 400 }}>
|
||||
<a
|
||||
href="/"
|
||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
|
||||
aria-label="VYNDR — home"
|
||||
>
|
||||
<Wordmark size={24} />
|
||||
<footer style={{ borderTop: '1px solid var(--border)', marginTop: 40, padding: '34px 24px' }}>
|
||||
<div style={{ maxWidth: 1320, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 28 }}>
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex' }}>
|
||||
<Wordmark size="sm" beta />
|
||||
</a>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
The books have every advantage. We built this to give it back.
|
||||
</p>
|
||||
<p className="mono" style={{ marginTop: 16, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
Built in Detroit.
|
||||
</p>
|
||||
<div style={{ ...mono, fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.7, marginTop: 14 }}>
|
||||
Prop intelligence the books don't want you to have. Analytics tool, not a sportsbook.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FooterColumn title="Product" links={PRIMARY_LINKS} />
|
||||
<FooterColumn title="Legal" links={LEGAL_LINKS} />
|
||||
<FooterColumn title="Community" links={SOCIAL} external />
|
||||
<div style={{ display: 'flex', gap: 48, flexWrap: 'wrap' }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.head}>
|
||||
<div className="label" style={{ fontSize: 9.5, marginBottom: 12 }}>{col.head}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
{col.links.map((l) => (
|
||||
<a key={l.label} href={l.href} className="footer-link" style={{ ...mono, fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginTop: 30,
|
||||
paddingTop: 20,
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 24,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.6 }}>
|
||||
VYNDR is an analytics tool, not a sportsbook. We don't accept wagers. Gamble responsibly.
|
||||
If you or someone you know has a gambling problem, call <strong style={{ color: 'var(--text-secondary)' }}>1-800-522-4700</strong>{' '}
|
||||
or visit <a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--grade-a)' }}>ncpgambling.org</a>.
|
||||
</p>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
© 2026 VYNDR. All rights reserved.
|
||||
</p>
|
||||
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
|
||||
VYNDR is an analytics tool, not a sportsbook. Not financial advice. Gamble responsibly. 21+ ·{' '}
|
||||
<a href="tel:18005224700" style={{ color: 'var(--amber)', textDecoration: 'none' }}>1-800-522-4700</a>
|
||||
</div>
|
||||
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
|
||||
BUILT BY KEVON BUTLER · DETROIT · © 2026 VYNDR
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
:global(.footer-top) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:global(.footer-top) {
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
<style>{`
|
||||
.footer-link { transition: color .15s; }
|
||||
.footer-link:hover { color: var(--text-0); }
|
||||
`}</style>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterColumn({
|
||||
title,
|
||||
links,
|
||||
external,
|
||||
}: {
|
||||
title: string;
|
||||
links: { label: string; href: string }[];
|
||||
external?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{links.map((l) => (
|
||||
<li key={l.label}>
|
||||
<a
|
||||
href={l.href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 14,
|
||||
transition: 'color 200ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user