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:
+90
-2
@@ -4,8 +4,96 @@
|
|||||||
2026-06-15
|
2026-06-15
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v33.0 — VYNDR 2.0 design system, Phase A+B: tokens + global CSS +
|
SHIP BUILD v34.0 — VYNDR 2.0 design system, Phase C: app shell — nav, routing,
|
||||||
fonts + glitch keyframes + shared components (Session 33)
|
auth gate, footer, 404 (Session 34)
|
||||||
|
|
||||||
|
## Session 34 (2026-06-15) — SHIPPED
|
||||||
|
|
||||||
|
Phase C of the VYNDR 2.0 conversion: the app shell every page sits inside.
|
||||||
|
Frontend-only; ZERO backend changes. Converted the nav/footer/404 to the design
|
||||||
|
and added the routing + auth-gate frame. Backend 1792 → **1818 tests** (+26),
|
||||||
|
142 suites, zero regressions. Web build clean (exit 0, 36 routes).
|
||||||
|
|
||||||
|
### Honest reconciliations (spec ↔ our reality — same discipline as the S32 NFL-key fix)
|
||||||
|
- **Auth gate is CLIENT-side, not server middleware.** The prompt's example used
|
||||||
|
`createMiddlewareClient` from `@supabase/auth-helpers-nextjs` — that package
|
||||||
|
isn't installed, our auth is client-side Supabase (session in localStorage via
|
||||||
|
`@supabase/supabase-js`), and the existing `middleware.ts` is locale-only. A
|
||||||
|
server middleware physically can't read that session. So the gate runs in
|
||||||
|
`<AuthGate>` (client) on top of the existing Supabase auth (rule 5 honored).
|
||||||
|
Locale middleware left untouched.
|
||||||
|
- **Narrowed the GATED set.** The prototype gated dashboard + scan; those are OUR
|
||||||
|
free-scan acquisition funnel (anon/free get 5 reads), so gating them is a
|
||||||
|
monetization regression. GATED = ledger/tracker/account/profile/settings/
|
||||||
|
notifications/invite (genuinely personal surfaces). dashboard/scan stay OPEN.
|
||||||
|
- **Did NOT stub over existing real pages.** The prompt's stub list assumed
|
||||||
|
ledger/tracker/blog/game/responsible/offline didn't exist — they do, with real
|
||||||
|
content. Stubs created ONLY for genuinely-missing routes.
|
||||||
|
- **Redirect param is `?next=`,** not the prompt's `?redirect=` — that's the
|
||||||
|
param our `/login` page already reads (`router.replace(next)`).
|
||||||
|
|
||||||
|
### C.1 — Traced the shell
|
||||||
|
Nav used legacy `@/components/Wordmark` (`.wordmark`). Auth = `AuthContext`/
|
||||||
|
`useAuth` (user/tier/scansRemaining/loading/signOut). `middleware.ts` = locale
|
||||||
|
only. Footer existed but was mounted only on the landing page. 28 route pages.
|
||||||
|
|
||||||
|
### C.2 — Nav (`components/Nav.tsx` rewritten)
|
||||||
|
- New `@/components/vyndr` Wordmark (size md, cursor, beta — `.wm` markup).
|
||||||
|
- Primary links SLATE/TERMINAL/SCAN/LEDGER + a **More** dropdown (Compare,
|
||||||
|
Tracker, The Report, Invite, Pricing, Settings). All nav chrome JetBrains Mono,
|
||||||
|
uppercase, 11px, 0.08em; active = `--g-a`, hover `--text-0`, default `--text-1`.
|
||||||
|
- Right cluster: `›` Query search trigger, NotificationBell (signed-in), read
|
||||||
|
meter (`n/5 · MO`, free + scan/dashboard only) / `∞ TIER` plan badge, avatar
|
||||||
|
menu (Account/Settings/Upgrade/Log out). Mobile toggle kept (full mobile shell
|
||||||
|
is Session 37).
|
||||||
|
- **Ticker** mounted under the bar (sample items; real feed = Session 38). Header
|
||||||
|
is now a fixed wrapper = 60px nav + 32px ticker → `layout` main `paddingTop`
|
||||||
|
64 → 96.
|
||||||
|
|
||||||
|
### C.3 — Routing + auth gate
|
||||||
|
- `web/src/lib/routes.js` (CommonJS, unit-testable): `GATED_ROUTES`,
|
||||||
|
`OPEN_ROUTES`, `HASH_ALIASES`, `isGatedRoute(path)`, `resolveHashAlias(hash)`.
|
||||||
|
- `components/AuthGate.tsx` — waits for `loading`, then bounces signed-out users
|
||||||
|
off gated routes to `/login?next=<path>`. Mounted around `<main>` in layout.
|
||||||
|
- `components/vyndr/HashRedirect.tsx` — maps `#scan`/`#terminal`/… to real routes
|
||||||
|
once on mount (keeps Next file-based routing; honors old share links). Mounted
|
||||||
|
in layout.
|
||||||
|
- Stub pages (design-system `RouteStub`, system language "ROUTE UNDER
|
||||||
|
CONSTRUCTION · SESSION xx"): `/terminal`, `/compare`, `/invite`, `/help`,
|
||||||
|
`/about`, `/notifications`.
|
||||||
|
|
||||||
|
### C.4 — Footer (`components/Footer.tsx` rewritten)
|
||||||
|
System voice: Wordmark sm+beta, mono columns PRODUCT/COMPANY/LEGAL, legal/21+
|
||||||
|
line with `--amber` 1-800-522-4700, and **BUILT BY KEVON BUTLER · DETROIT ·
|
||||||
|
© 2026 VYNDR**. Now mounted GLOBALLY in the layout (removed the per-page import
|
||||||
|
from the landing page to avoid a double footer).
|
||||||
|
|
||||||
|
### C.5 — 404 north star (`app/not-found.tsx` rewritten)
|
||||||
|
Full-page `.scanlines`, `.crt-sweep` on load, glitch Wordmark (lg), amber-glow
|
||||||
|
"TRANSMISSION INTERRUPTED", giant `--amber` 404 (`.wm` data-text), "This page
|
||||||
|
doesn't exist. / The signal was lost.", VBtn CTAs (client `NotFoundActions`
|
||||||
|
so the page stays a server component for metadata).
|
||||||
|
|
||||||
|
### Files created
|
||||||
|
- `web/src/lib/routes.js`
|
||||||
|
- `web/src/components/AuthGate.tsx`
|
||||||
|
- `web/src/components/vyndr/{HashRedirect,NotFoundActions,RouteStub}.tsx`
|
||||||
|
- `web/src/app/{terminal,compare,invite,help,about,notifications}/page.tsx`
|
||||||
|
- `tests/unit/vyndrAppShell.test.js` (26 tests: routing logic, gate, nav/footer/
|
||||||
|
404 contracts, layout wiring, stubs-don't-clobber-real-pages)
|
||||||
|
|
||||||
|
### Files modified
|
||||||
|
- `web/src/components/Nav.tsx`, `web/src/components/Footer.tsx`
|
||||||
|
- `web/src/app/layout.tsx` (mount HashRedirect + AuthGate + global Footer,
|
||||||
|
paddingTop 96), `web/src/app/not-found.tsx`, `web/src/app/page.tsx` (drop dup
|
||||||
|
footer)
|
||||||
|
|
||||||
|
### Deferred (Sessions 35+)
|
||||||
|
- Real screens for the stubbed routes (D/E), mobile shell (F), living-layer
|
||||||
|
ticker/heartbeat data + nav-string i18n (G). The Nav search `›` currently links
|
||||||
|
to `/scan`; the ⌘K command palette is Phase G.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session 33 (2026-06-15) — SHIPPED
|
## Session 33 (2026-06-15) — SHIPPED
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,38 @@ Multi-session frontend conversion of the claude.ai/design "VYNDR 2.0" handoff
|
|||||||
data-motion>` overrides in globals.css. Wiring the toggles to these attrs is
|
data-motion>` overrides in globals.css. Wiring the toggles to these attrs is
|
||||||
Phase G (Session 38).
|
Phase G (Session 38).
|
||||||
|
|
||||||
|
## VYNDR 2.0 App Shell (Session 34 — Phase C)
|
||||||
|
The frame every page sits in. Frontend-only.
|
||||||
|
- **Routing config** = `web/src/lib/routes.js` (CommonJS so it's unit-testable):
|
||||||
|
`GATED_ROUTES`, `OPEN_ROUTES`, `HASH_ALIASES`, `isGatedRoute()`,
|
||||||
|
`resolveHashAlias()`. GATED is deliberately narrow — only personal surfaces
|
||||||
|
(ledger/tracker/account/profile/settings/notifications/invite). dashboard +
|
||||||
|
scan stay OPEN (the free-scan funnel); gating them would be a monetization
|
||||||
|
regression.
|
||||||
|
- **Auth gate = CLIENT-side** (`components/AuthGate.tsx`, mounted around `<main>`
|
||||||
|
in layout). Our Supabase session lives in localStorage, not an httpOnly cookie,
|
||||||
|
and `middleware.ts` is locale-only — a server middleware can't read it. The
|
||||||
|
gate uses `useAuth().loading/user` + `isGatedRoute()` and redirects to
|
||||||
|
`/login?next=<path>` (the param `/login` already consumes). Do NOT try to move
|
||||||
|
this to middleware without first adding `@supabase/auth-helpers-nextjs` +
|
||||||
|
cookie sessions (a separate, larger change).
|
||||||
|
- **Hash deep-links**: `components/vyndr/HashRedirect.tsx` translates
|
||||||
|
`#scan`/`#terminal`/… → real Next routes once on mount. We keep file-based
|
||||||
|
routing; hashes are just redirect aliases for old share links / PWA shortcuts.
|
||||||
|
- **Nav** (`components/Nav.tsx`): uses the new `@/components/vyndr` Wordmark
|
||||||
|
(`.wm`), mono uppercase links, active = `--g-a`, a More dropdown, and a
|
||||||
|
**Ticker** under the bar. The fixed header is 60px nav + 32px ticker, so layout
|
||||||
|
`main` paddingTop is **96** (not 64) — keep that in sync if the header height
|
||||||
|
changes.
|
||||||
|
- **Footer** (`components/Footer.tsx`) is mounted GLOBALLY in the layout (not
|
||||||
|
per-page). System voice + "BUILT BY KEVON BUTLER · DETROIT".
|
||||||
|
- **404** (`app/not-found.tsx`) is the north star — scanlines, crt-sweep, glitch
|
||||||
|
wordmark, amber 404. Interactive CTAs live in client `NotFoundActions` so the
|
||||||
|
page stays a server component (keeps its metadata export).
|
||||||
|
- **RouteStub** (`components/vyndr/RouteStub.tsx`) backs not-yet-built routes
|
||||||
|
(terminal/compare/invite/help/about/notifications). Never stub over a route
|
||||||
|
that already has real content.
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// VYNDR 2.0 — Phase C app shell (Session 34): routing config + auth gate,
|
||||||
|
// nav, footer, 404, route stubs. Routing LOGIC is exercised directly via the
|
||||||
|
// CommonJS routes module; the .tsx shell is asserted against its source text
|
||||||
|
// (the plain-JS Jest config has no TS/Babel transform).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
|
||||||
|
|
||||||
|
const routes = require('../../web/src/lib/routes');
|
||||||
|
|
||||||
|
describe('Phase C — routing config (lib/routes)', () => {
|
||||||
|
it('gates the personal-data routes', () => {
|
||||||
|
['/ledger', '/tracker', '/account', '/profile', '/notifications', '/invite'].forEach((r) => {
|
||||||
|
expect(routes.isGatedRoute(r)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates nested paths under a gated route', () => {
|
||||||
|
expect(routes.isGatedRoute('/ledger/2026-06')).toBe(true);
|
||||||
|
expect(routes.isGatedRoute('/settings/security')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the landing + free funnel open (dashboard, scan stay public)', () => {
|
||||||
|
['/', '/dashboard', '/scan', '/pricing', '/blog', '/terminal', '/login'].forEach((r) => {
|
||||||
|
expect(routes.isGatedRoute(r)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty/undefined pathnames without throwing', () => {
|
||||||
|
expect(routes.isGatedRoute('')).toBe(false);
|
||||||
|
expect(routes.isGatedRoute(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves hash deep-link aliases to real routes', () => {
|
||||||
|
expect(routes.resolveHashAlias('#scan')).toBe('/scan');
|
||||||
|
expect(routes.resolveHashAlias('#terminal')).toBe('/terminal');
|
||||||
|
expect(routes.resolveHashAlias('#slate')).toBe('/dashboard');
|
||||||
|
expect(routes.resolveHashAlias('#nope')).toBeNull();
|
||||||
|
expect(routes.resolveHashAlias('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps GATED and OPEN route lists disjoint', () => {
|
||||||
|
const overlap = routes.GATED_ROUTES.filter((r) => routes.OPEN_ROUTES.includes(r));
|
||||||
|
expect(overlap).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — AuthGate (client-side gate, keeps Supabase auth)', () => {
|
||||||
|
const src = read('components/AuthGate.tsx');
|
||||||
|
it('decides gating from lib/routes, not a server middleware', () => {
|
||||||
|
expect(src).toContain("from '@/lib/routes'");
|
||||||
|
expect(src).toContain('isGatedRoute');
|
||||||
|
expect(src).toContain('useAuth');
|
||||||
|
});
|
||||||
|
it('redirects to /login carrying the intended path via ?next=', () => {
|
||||||
|
expect(src).toContain('/login?next=');
|
||||||
|
});
|
||||||
|
it('waits for auth to finish loading before redirecting', () => {
|
||||||
|
expect(src).toMatch(/if \(loading\) return/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — Nav conversion', () => {
|
||||||
|
const src = read('components/Nav.tsx');
|
||||||
|
it('uses the new VYNDR 2.0 Wordmark (.wm), not the legacy one', () => {
|
||||||
|
expect(src).toContain("from '@/components/vyndr'");
|
||||||
|
expect(src).not.toContain("from '@/components/Wordmark'");
|
||||||
|
});
|
||||||
|
it('renders the Ticker under the bar', () => {
|
||||||
|
expect(src).toContain('<Ticker');
|
||||||
|
});
|
||||||
|
it('styles nav links in JetBrains Mono with grade-green active state', () => {
|
||||||
|
expect(src).toContain("fontFamily: 'var(--mono)'");
|
||||||
|
expect(src).toContain("'var(--g-a)'");
|
||||||
|
});
|
||||||
|
it('exposes the primary Slate/Terminal/Scan/Ledger routes', () => {
|
||||||
|
['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => {
|
||||||
|
expect(src).toContain(href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — Footer (system voice)', () => {
|
||||||
|
const src = read('components/Footer.tsx');
|
||||||
|
it('uses the new Wordmark and mono type', () => {
|
||||||
|
expect(src).toContain("from '@/components/vyndr'");
|
||||||
|
expect(src).toContain('var(--mono)');
|
||||||
|
});
|
||||||
|
it('carries the Detroit signature + legal/21+ system language', () => {
|
||||||
|
expect(src).toContain('DETROIT');
|
||||||
|
expect(src).toContain('21+');
|
||||||
|
expect(src).toContain('not a sportsbook');
|
||||||
|
expect(src).toContain('Not financial advice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — 404 north star', () => {
|
||||||
|
const src = read('app/not-found.tsx');
|
||||||
|
it('renders the amber 404 with system language', () => {
|
||||||
|
expect(src).toContain('TRANSMISSION INTERRUPTED');
|
||||||
|
expect(src).toContain('404');
|
||||||
|
expect(src).toContain('var(--amber)');
|
||||||
|
});
|
||||||
|
it('uses the scanline texture and a CRT sweep on load', () => {
|
||||||
|
expect(src).toContain('scanlines');
|
||||||
|
expect(src).toContain('crt-sweep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — layout wiring', () => {
|
||||||
|
const src = read('app/layout.tsx');
|
||||||
|
it('mounts AuthGate, global Footer, and the hash bridge', () => {
|
||||||
|
expect(src).toContain('<AuthGate>');
|
||||||
|
expect(src).toContain('<Footer />');
|
||||||
|
expect(src).toContain('<HashRedirect />');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase C — route stubs for not-yet-built screens', () => {
|
||||||
|
const stubs = ['terminal', 'compare', 'invite', 'help', 'about', 'notifications'];
|
||||||
|
it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => {
|
||||||
|
expect(exists(`app/${name}/page.tsx`)).toBe(true);
|
||||||
|
expect(read(`app/${name}/page.tsx`)).toContain('RouteStub');
|
||||||
|
});
|
||||||
|
it('does NOT stub over existing real pages (ledger/tracker/blog/game)', () => {
|
||||||
|
// these predate Session 34 with real content — must not be RouteStubs
|
||||||
|
['app/ledger/page.tsx', 'app/tracker/page.tsx', 'app/blog/page.tsx'].forEach((p) => {
|
||||||
|
expect(read(p)).not.toContain('RouteStub');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('RouteStub speaks the system language', () => {
|
||||||
|
const src = read('components/vyndr/RouteStub.tsx');
|
||||||
|
expect(src).toContain('ROUTE UNDER CONSTRUCTION');
|
||||||
|
expect(src).toContain('SectionHead');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'About' };
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="About"
|
||||||
|
arriving="SESSION 36"
|
||||||
|
blurb="The books have every advantage. We built this to give it back. Built by Kevon Butler · Detroit."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Compare' };
|
||||||
|
|
||||||
|
export default function ComparePage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="Compare"
|
||||||
|
arriving="SESSION 36"
|
||||||
|
blurb="Two players head-to-head — markets, form, and the verdict on who has the edge tonight."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Help' };
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="Help & FAQ"
|
||||||
|
arriving="SESSION 36"
|
||||||
|
blurb="Searchable answers on grades, reads, plans, and how to read the signal."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Invite' };
|
||||||
|
|
||||||
|
export default function InvitePage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="Invite"
|
||||||
|
arriving="SESSION 36"
|
||||||
|
blurb="Bring three friends onto the signal and your Analyst plan is on the house."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import AuthProvider from '@/contexts/AuthContext';
|
|||||||
import ParlayProvider from '@/contexts/ParlayContext';
|
import ParlayProvider from '@/contexts/ParlayContext';
|
||||||
import ExplainModeProvider from '@/contexts/ExplainModeContext';
|
import ExplainModeProvider from '@/contexts/ExplainModeContext';
|
||||||
import Nav from '@/components/Nav';
|
import Nav from '@/components/Nav';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import AuthGate from '@/components/AuthGate';
|
||||||
|
import HashRedirect from '@/components/vyndr/HashRedirect';
|
||||||
import ParlayTray from '@/components/ParlayTray';
|
import ParlayTray from '@/components/ParlayTray';
|
||||||
import BottomTabBar from '@/components/BottomTabBar';
|
import BottomTabBar from '@/components/BottomTabBar';
|
||||||
import InstallPrompt from '@/components/InstallPrompt';
|
import InstallPrompt from '@/components/InstallPrompt';
|
||||||
@@ -120,8 +123,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ExplainModeProvider>
|
<ExplainModeProvider>
|
||||||
<ParlayProvider>
|
<ParlayProvider>
|
||||||
|
<HashRedirect />
|
||||||
<Nav />
|
<Nav />
|
||||||
<main style={{ paddingTop: 64, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
{/* Header = 60px nav + 32px ticker; offset main so content clears it. */}
|
||||||
|
<AuthGate>
|
||||||
|
<main style={{ paddingTop: 96, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||||
|
</AuthGate>
|
||||||
|
<Footer />
|
||||||
<ParlayTray />
|
<ParlayTray />
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|||||||
+43
-25
@@ -1,50 +1,68 @@
|
|||||||
import Link from 'next/link';
|
import { Wordmark } from '@/components/vyndr';
|
||||||
import Wordmark from '@/components/Wordmark';
|
import NotFoundActions from '@/components/vyndr/NotFoundActions';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: '404 — Signal Lost',
|
title: '404 — Signal Lost',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The north star (§3). The brand distilled: full-page scanlines, the glitch
|
||||||
|
* wordmark, system language ("TRANSMISSION INTERRUPTED"), a giant glowing amber
|
||||||
|
* 404, and a CRT sweep on load. Every page should aspire to this intentionality.
|
||||||
|
*/
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="tex-scan"
|
className="scanlines"
|
||||||
style={{
|
style={{
|
||||||
minHeight: 'calc(100vh - 144px)',
|
minHeight: 'calc(100vh - 140px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 24,
|
gap: 0,
|
||||||
padding: '32px 16px',
|
padding: '40px 24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Wordmark size={28} />
|
{/* CRT sweep — fires its 0.7s animation once on mount */}
|
||||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>TRANSMISSION INTERRUPTED</p>
|
<div className="crt-sweep" />
|
||||||
<h1
|
|
||||||
className="num"
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<Wordmark size="lg" cursor beta />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mono amber-glow"
|
||||||
|
style={{ fontSize: 13, letterSpacing: '0.28em', color: 'var(--amber)', marginBottom: 22 }}
|
||||||
|
>
|
||||||
|
TRANSMISSION INTERRUPTED
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="wm"
|
||||||
|
data-text="404"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 'clamp(80px, 16vw, 160px)',
|
fontSize: 'clamp(96px, 16vw, 150px)',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: 'var(--grade-c)',
|
color: 'var(--amber)',
|
||||||
textShadow: '0 0 24px rgba(255, 179, 71, 0.6), 0 0 48px rgba(255, 179, 71, 0.25)',
|
textShadow: '0 0 24px rgba(255,179,71,.6), 0 0 60px rgba(255,179,71,.3)',
|
||||||
lineHeight: 0.9,
|
lineHeight: 1,
|
||||||
letterSpacing: '-0.04em',
|
letterSpacing: '-0.02em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
404
|
404
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 480 }}>
|
|
||||||
This page doesn't exist. The signal was lost.
|
|
||||||
</p>
|
|
||||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 480 }}>
|
|
||||||
Check the URL, head back to the slate, or open the Ledger to review past grades.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
||||||
<Link href="/dashboard" className="btn-primary">Back to Dashboard →</Link>
|
|
||||||
<Link href="/ledger" className="btn-ghost">Open the Ledger</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-0.02em', margin: '26px 0 0', color: 'var(--text-0)' }}>
|
||||||
|
This page doesn't exist.
|
||||||
|
</h1>
|
||||||
|
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', marginTop: 10 }}>
|
||||||
|
The signal was lost.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NotFoundActions />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Notifications' };
|
||||||
|
|
||||||
|
// Gated route (lib/routes.js) — AuthGate bounces signed-out visitors to /login.
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="Notifications"
|
||||||
|
arriving="SESSION 36"
|
||||||
|
blurb="Your inbox of A+ signals, injury cascades, and graded results."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import Features from '@/components/Features';
|
|||||||
import HowItWorks from '@/components/HowItWorks';
|
import HowItWorks from '@/components/HowItWorks';
|
||||||
import Pricing from '@/components/Pricing';
|
import Pricing from '@/components/Pricing';
|
||||||
import FAQ from '@/components/FAQ';
|
import FAQ from '@/components/FAQ';
|
||||||
import Footer from '@/components/Footer';
|
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -54,7 +54,6 @@ export default function Home() {
|
|||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
<Pricing />
|
<Pricing />
|
||||||
<FAQ />
|
<FAQ />
|
||||||
<Footer />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import RouteStub from '@/components/vyndr/RouteStub';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Terminal' };
|
||||||
|
|
||||||
|
export default function TerminalPage() {
|
||||||
|
return (
|
||||||
|
<RouteStub
|
||||||
|
title="The Terminal"
|
||||||
|
arriving="SESSION 35"
|
||||||
|
blurb="League intelligence: injury cascades, game-impact scores, gradeable leaders, factor pulse, matchup exploits."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { isGatedRoute } from '@/lib/routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side auth gate (§12). Our session lives in the Supabase client
|
||||||
|
* (localStorage), not an httpOnly cookie a server middleware could read — so
|
||||||
|
* the gate runs here, in the browser, on top of the existing Supabase auth.
|
||||||
|
*
|
||||||
|
* Gated routes (a user's own ledger / tracker / account / alerts — see
|
||||||
|
* lib/routes.js) bounce signed-out visitors to /login, remembering where they
|
||||||
|
* were headed via the `?next=` param the login page already consumes. We wait
|
||||||
|
* for auth to finish loading before deciding, so a logged-in user is never
|
||||||
|
* flashed to /login on a hard refresh.
|
||||||
|
*/
|
||||||
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const pathname = usePathname() || '';
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user && isGatedRoute(pathname)) {
|
||||||
|
const next = encodeURIComponent(pathname);
|
||||||
|
router.replace(`/login?next=${next}`);
|
||||||
|
}
|
||||||
|
}, [user, loading, pathname, router]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
+72
-129
@@ -1,151 +1,94 @@
|
|||||||
const PRIMARY_LINKS = [
|
import { Wordmark } from '@/components/vyndr';
|
||||||
{ label: 'Read', href: '/scan' },
|
|
||||||
{ label: 'Tracker', href: '/tracker' },
|
// VYNDR 2.0 footer (§C.4). System language, not marketing — mono, uppercase
|
||||||
{ label: 'Ledger', href: '/ledger' },
|
// labels, the Detroit signature, and the legal/21+ line. Server component
|
||||||
{ label: 'Pricing', href: '/#pricing' },
|
// (no interactivity); link hover is handled by the global .footer-link rule.
|
||||||
{ label: 'Blog', href: '/blog' },
|
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 = [
|
const mono = { fontFamily: 'var(--mono)' } as const;
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer style={{ borderTop: '1px solid var(--border)', marginTop: 40, padding: '34px 24px' }}>
|
||||||
style={{
|
<div style={{ maxWidth: 1320, margin: '0 auto' }}>
|
||||||
borderTop: '1px solid var(--border)',
|
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 28 }}>
|
||||||
padding: '64px 24px 32px',
|
<div style={{ maxWidth: 300 }}>
|
||||||
marginTop: 64,
|
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex' }}>
|
||||||
}}
|
<Wordmark size="sm" beta />
|
||||||
>
|
|
||||||
<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} />
|
|
||||||
</a>
|
</a>
|
||||||
<p
|
<div style={{ ...mono, fontSize: 11.5, color: 'var(--text-2)', lineHeight: 1.7, marginTop: 14 }}>
|
||||||
style={{
|
Prop intelligence the books don't want you to have. Analytics tool, not a sportsbook.
|
||||||
marginTop: 12,
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<FooterColumn title="Product" links={PRIMARY_LINKS} />
|
<div style={{ display: 'flex', gap: 48, flexWrap: 'wrap' }}>
|
||||||
<FooterColumn title="Legal" links={LEGAL_LINKS} />
|
{COLUMNS.map((col) => (
|
||||||
<FooterColumn title="Community" links={SOCIAL} external />
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 30,
|
||||||
|
paddingTop: 20,
|
||||||
borderTop: '1px solid var(--border)',
|
borderTop: '1px solid var(--border)',
|
||||||
paddingTop: 24,
|
|
||||||
display: 'grid',
|
|
||||||
gap: 16,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.6 }}>
|
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
|
||||||
VYNDR is an analytics tool, not a sportsbook. We don't accept wagers. Gamble responsibly.
|
VYNDR is an analytics tool, not a sportsbook. Not financial advice. Gamble responsibly. 21+ ·{' '}
|
||||||
If you or someone you know has a gambling problem, call <strong style={{ color: 'var(--text-secondary)' }}>1-800-522-4700</strong>{' '}
|
<a href="tel:18005224700" style={{ color: 'var(--amber)', textDecoration: 'none' }}>1-800-522-4700</a>
|
||||||
or visit <a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--grade-a)' }}>ncpgambling.org</a>.
|
</div>
|
||||||
</p>
|
<div style={{ ...mono, fontSize: 11, color: 'var(--text-2)' }}>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
BUILT BY KEVON BUTLER · DETROIT · © 2026 VYNDR
|
||||||
© 2026 VYNDR. All rights reserved.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
<style>{`
|
||||||
:global(.footer-top) {
|
.footer-link { transition: color .15s; }
|
||||||
grid-template-columns: 1fr;
|
.footer-link:hover { color: var(--text-0); }
|
||||||
}
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
:global(.footer-top) {
|
|
||||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
</footer>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+347
-271
@@ -3,314 +3,378 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Wordmark from '@/components/Wordmark';
|
import { Wordmark, Ticker } from '@/components/vyndr';
|
||||||
import NotificationBell from '@/components/NotificationBell';
|
import NotificationBell from '@/components/NotificationBell';
|
||||||
// Session 24 — LocaleSwitcher removed from the nav. The i18n
|
// Nav labels are English literals for now; nav-string i18n lands in Phase G
|
||||||
// infrastructure (react-i18next, LocaleContext, useT) stays in place,
|
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
|
||||||
// but a visible language toggle with no translations behind it is
|
|
||||||
// worse than none. Re-add the switcher when translations land.
|
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
|
||||||
import { useT } from '@/contexts/LocaleContext';
|
// LEDGER; everything else lives under a More dropdown. All nav chrome is
|
||||||
|
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
|
||||||
|
// route is grade-green (--g-a); a Ticker runs under the bar.
|
||||||
|
const PRIMARY = [
|
||||||
|
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
||||||
|
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
|
||||||
|
{ id: 'scan', label: 'Scan', href: '/scan' },
|
||||||
|
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||||
|
];
|
||||||
|
const MORE = [
|
||||||
|
{ label: 'Compare', href: '/compare' },
|
||||||
|
{ label: 'Tracker', href: '/tracker' },
|
||||||
|
{ label: 'The Report', href: '/blog' },
|
||||||
|
{ label: 'Invite', href: '/invite' },
|
||||||
|
{ label: 'Pricing', href: '/pricing' },
|
||||||
|
{ label: 'Settings', href: '/settings/security' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TICKER_ITEMS = [
|
||||||
|
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
|
||||||
|
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
|
||||||
|
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
|
||||||
|
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(pathname: string, href: string) {
|
||||||
|
return pathname === href || pathname.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkStyle = (active: boolean) => ({
|
||||||
|
fontFamily: 'var(--mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
color: active ? 'var(--g-a)' : 'var(--text-1)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
transition: 'color .15s',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px 4px',
|
||||||
|
});
|
||||||
|
|
||||||
export default function Nav() {
|
export default function Nav() {
|
||||||
const { user, tier, scansRemaining, signOut } = useAuth();
|
const { user, tier, scansRemaining, signOut } = useAuth();
|
||||||
const t = useT();
|
|
||||||
const pathname = usePathname() || '';
|
const pathname = usePathname() || '';
|
||||||
// Session 17 — read counter sits in the global nav, but the audit
|
|
||||||
// flagged it as noise outside the scan flow. Restrict it to /scan
|
|
||||||
// and /dashboard (where the slate-scan lives) so it acts as a
|
|
||||||
// quota indicator next to the action it gates, not a chrome pill.
|
|
||||||
const showReadCounter = pathname === '/scan' || pathname.startsWith('/scan/')
|
|
||||||
|| pathname === '/dashboard' || pathname.startsWith('/dashboard/');
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
// Session 12 — translation labels resolved at render time so a
|
const showReadCounter =
|
||||||
// locale switch flips the nav without a code change.
|
pathname.startsWith('/scan') || pathname.startsWith('/dashboard');
|
||||||
// Session 13 — "Scan" removed from the primary nav: The Slate on
|
const moreActive = MORE.some((l) => isActive(pathname, l.href));
|
||||||
// /dashboard IS the scan surface (click [Read] on any prop). The
|
|
||||||
// /scan page still exists as a fallback for custom props and is
|
|
||||||
// reachable from the slate's "Scan manually" empty-state CTA.
|
|
||||||
// Session 24 — paid users (analyst / desk) get "Account" where free
|
|
||||||
// users and signed-out visitors see "Pricing". A subscriber shouldn't
|
|
||||||
// be pitched a plan they already pay for.
|
|
||||||
const isPaid = !!user && tier !== 'free';
|
|
||||||
const NAV_LINKS = [
|
|
||||||
{ label: t('nav.tracker'), href: '/tracker' },
|
|
||||||
{ label: t('nav.ledger'), href: '/ledger' },
|
|
||||||
isPaid
|
|
||||||
? { label: 'Account', href: '/account' }
|
|
||||||
: { label: t('nav.pricing'), href: '/pricing' },
|
|
||||||
{ label: 'Blog', href: '/blog' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}>
|
||||||
style={{
|
<nav
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
height: 64,
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
background: 'rgba(10, 10, 15, 0.85)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 1280,
|
height: 60,
|
||||||
margin: '0 auto',
|
borderBottom: '1px solid var(--border)',
|
||||||
padding: '0 24px',
|
background: 'rgba(6, 6, 11, 0.86)',
|
||||||
height: '100%',
|
backdropFilter: 'blur(10px)',
|
||||||
display: 'flex',
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 24,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
<div
|
||||||
href="/"
|
style={{
|
||||||
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 10 }}
|
maxWidth: 1320,
|
||||||
aria-label="VYNDR — home"
|
margin: '0 auto',
|
||||||
|
padding: '0 24px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 18,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Wordmark size={22} />
|
{/* Left — wordmark + primary links */}
|
||||||
{/* Session 8 — beta tag. Tiny, glitch-styled, sits next to
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||||
the wordmark so it reads as part of the brand rather than
|
<a href="/" aria-label="VYNDR — home" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
a banner. Renders on every page that mounts Nav. */}
|
<Wordmark size="md" cursor beta />
|
||||||
<span
|
|
||||||
className="mono"
|
|
||||||
aria-label="Beta"
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: '0.14em',
|
|
||||||
padding: '2px 5px',
|
|
||||||
color: 'var(--grade-a)',
|
|
||||||
border: '1px solid var(--grade-a)',
|
|
||||||
borderRadius: 3,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
opacity: 0.85,
|
|
||||||
lineHeight: 1,
|
|
||||||
position: 'relative',
|
|
||||||
top: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
BETA
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
|
|
||||||
{NAV_LINKS.map((l) => (
|
|
||||||
<a
|
|
||||||
key={l.href}
|
|
||||||
href={l.href}
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
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>
|
</a>
|
||||||
))}
|
<div className="nav-desktop" style={{ display: 'none', gap: 14, alignItems: 'center' }}>
|
||||||
|
{PRIMARY.map((l) => (
|
||||||
{user ? (
|
<a
|
||||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
key={l.id}
|
||||||
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
href={l.href}
|
||||||
<span
|
className="glitch-hover"
|
||||||
className="mono"
|
style={linkStyle(isActive(pathname, l.href))}
|
||||||
style={{
|
onMouseEnter={(e) => {
|
||||||
fontSize: 12,
|
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-0)';
|
||||||
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isActive(pathname, l.href)) e.currentTarget.style.color = 'var(--text-1)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{scansRemaining}/5 reads · MO
|
{l.label}
|
||||||
</span>
|
</a>
|
||||||
)}
|
))}
|
||||||
<NotificationBell />
|
{/* More dropdown */}
|
||||||
<button
|
<div style={{ position: 'relative' }}>
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
<button
|
||||||
aria-haspopup="menu"
|
onClick={() => setMoreOpen((o) => !o)}
|
||||||
aria-expanded={menuOpen}
|
aria-haspopup="menu"
|
||||||
style={{
|
aria-expanded={moreOpen}
|
||||||
width: 36,
|
style={linkStyle(moreActive)}
|
||||||
height: 36,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--bg-elevated)',
|
|
||||||
border: '1px solid var(--border-focus)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.email?.charAt(0).toUpperCase()}
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
className="surface-elevated"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 'calc(100% + 8px)',
|
|
||||||
minWidth: 220,
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
More ▾
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Signed in as</div>
|
</button>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
{moreOpen && (
|
||||||
{user.email}
|
<div
|
||||||
</div>
|
role="menu"
|
||||||
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--grade-a)', textTransform: 'uppercase' }}>
|
onMouseLeave={() => setMoreOpen(false)}
|
||||||
{tier} tier
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{tier === 'free' && (
|
|
||||||
<a
|
|
||||||
href="/#pricing"
|
|
||||||
role="menuitem"
|
|
||||||
style={{ display: 'block', padding: '10px 12px', fontSize: 13, color: 'var(--text-primary)', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
Upgrade — $14.99/mo
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void signOut();
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
role="menuitem"
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
position: 'absolute',
|
||||||
textAlign: 'left',
|
left: 0,
|
||||||
padding: '10px 12px',
|
top: 'calc(100% + 8px)',
|
||||||
background: 'transparent',
|
minWidth: 180,
|
||||||
border: 'none',
|
background: 'var(--bg-2)',
|
||||||
color: 'var(--text-secondary)',
|
border: '1px solid var(--border-hi)',
|
||||||
fontSize: 13,
|
borderRadius: 8,
|
||||||
cursor: 'pointer',
|
padding: 6,
|
||||||
fontFamily: 'inherit',
|
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Log out
|
{MORE.map((l) => (
|
||||||
</button>
|
<a
|
||||||
</div>
|
key={l.href}
|
||||||
)}
|
href={l.href}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: 'var(--mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-1)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
|
|
||||||
{t('nav.login')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{/* Right — search, bell, read meter / plan, avatar */}
|
||||||
className="nav-mobile-toggle"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
aria-label="Toggle menu"
|
|
||||||
aria-expanded={mobileOpen}
|
|
||||||
onClick={() => setMobileOpen((o) => !o)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 8,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mobileOpen ? '×' : '≡'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mobileOpen && (
|
|
||||||
<div
|
|
||||||
className="nav-mobile-panel"
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'grid', gap: 4 }}>
|
|
||||||
{NAV_LINKS.map((l) => (
|
|
||||||
<a
|
|
||||||
key={l.href}
|
|
||||||
href={l.href}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
fontSize: 15,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
{/* Session 14 — mobile-only "Scan manually" link. The Slate
|
|
||||||
IS the scan surface on /dashboard, but power users on
|
|
||||||
mobile may want a direct route to the form. Subtle
|
|
||||||
tertiary styling so it doesn't compete with the
|
|
||||||
primary nav links. */}
|
|
||||||
<a
|
<a
|
||||||
href="/scan"
|
href="/scan"
|
||||||
onClick={() => setMobileOpen(false)}
|
title="Query the system"
|
||||||
|
className="mono"
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 16px',
|
display: 'none',
|
||||||
fontSize: 13,
|
alignItems: 'center',
|
||||||
color: 'var(--text-secondary, #8A8A9A)',
|
gap: 7,
|
||||||
|
height: 28,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderRadius: 7,
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
background: 'var(--bg-2)',
|
||||||
|
color: 'var(--text-1)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
borderRadius: 8,
|
fontSize: 11,
|
||||||
borderTop: '1px solid var(--border)',
|
letterSpacing: '0.06em',
|
||||||
marginTop: 4,
|
|
||||||
paddingTop: 16,
|
|
||||||
}}
|
}}
|
||||||
|
data-search-trigger
|
||||||
>
|
>
|
||||||
Scan manually →
|
<span style={{ color: 'var(--g-a)' }}>›</span>
|
||||||
|
<span>Query</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{user && <NotificationBell />}
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<button
|
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
onClick={() => {
|
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||||||
void signOut();
|
<span
|
||||||
setMobileOpen(false);
|
className="mono"
|
||||||
}}
|
style={{
|
||||||
style={{
|
fontSize: 11,
|
||||||
textAlign: 'left',
|
fontWeight: 700,
|
||||||
padding: '12px 16px',
|
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
|
||||||
fontSize: 15,
|
}}
|
||||||
color: 'var(--text-secondary)',
|
>
|
||||||
background: 'transparent',
|
{scansRemaining}/5 · MO
|
||||||
border: 'none',
|
</span>
|
||||||
cursor: 'pointer',
|
)}
|
||||||
fontFamily: 'inherit',
|
{tier !== 'free' && (
|
||||||
}}
|
<span
|
||||||
>
|
className="mono"
|
||||||
Log out
|
title="Unlimited on your plan"
|
||||||
</button>
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--g-a)',
|
||||||
|
border: '1px solid rgba(0,212,160,.3)',
|
||||||
|
borderRadius: 100,
|
||||||
|
padding: '4px 10px',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
∞ {tier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, var(--acc-1), var(--bg-3))',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
color: 'var(--g-a)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--mono)',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email?.charAt(0).toUpperCase() || 'V'}
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
onMouseLeave={() => setMenuOpen(false)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 'calc(100% + 8px)',
|
||||||
|
minWidth: 220,
|
||||||
|
background: 'var(--bg-2)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
boxShadow: '0 12px 32px rgba(0,0,0,.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
Signed in as
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--g-a)', textTransform: 'uppercase' }}>
|
||||||
|
{tier} tier
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/account" role="menuitem" style={menuItem}>Account</a>
|
||||||
|
<a href="/settings/security" role="menuitem" style={menuItem}>Settings</a>
|
||||||
|
{tier === 'free' && (
|
||||||
|
<a href="/pricing" role="menuitem" style={{ ...menuItem, color: 'var(--g-a)' }}>
|
||||||
|
Upgrade — $14.99/mo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void signOut();
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
style={{ ...menuItem, width: '100%', textAlign: 'left', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
className="btn-primary"
|
className="mono"
|
||||||
style={{ marginTop: 8, padding: 12 }}
|
style={{
|
||||||
onClick={() => setMobileOpen(false)}
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: '7px 14px',
|
||||||
|
borderRadius: 7,
|
||||||
|
border: '1px solid var(--g-a)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--g-a)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Log In
|
Sign In
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="nav-mobile-toggle"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
onClick={() => setMobileOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 6,
|
||||||
|
color: 'var(--text-0)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mobileOpen ? '×' : '≡'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="nav-mobile-panel" style={{ borderTop: '1px solid var(--border)', background: 'var(--bg-1)', padding: 12 }}>
|
||||||
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
|
{[...PRIMARY.map((p) => ({ label: p.label, href: p.href })), ...MORE].map((l) => (
|
||||||
|
<a
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontFamily: 'var(--mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: isActive(pathname, l.href) ? 'var(--g-a)' : 'var(--text-0)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{user ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void signOut();
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ textAlign: 'left', padding: '12px 14px', fontSize: 13, color: 'var(--text-1)', background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a href="/login" onClick={() => setMobileOpen(false)} style={{ marginTop: 6, padding: 12, textAlign: 'center', borderRadius: 7, border: '1px solid var(--g-a)', color: 'var(--g-a)', textDecoration: 'none', fontFamily: 'var(--mono)', fontWeight: 700, textTransform: 'uppercase' }}>
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Ticker under the bar — sample data; real feed wires in Session 38 */}
|
||||||
|
<Ticker items={TICKER_ITEMS} height={32} />
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -323,8 +387,20 @@ export default function Nav() {
|
|||||||
:global(.nav-mobile-panel) {
|
:global(.nav-mobile-panel) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
:global([data-search-trigger]) {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</nav>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuItem: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text-1)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontFamily: 'var(--sans)',
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { resolveHashAlias } from '@/lib/routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash deep-link bridge (§C.3.4). The VYNDR 2.0 prototype was a single HTML
|
||||||
|
* file routed by hash (#scan, #terminal, …). We keep Next file-based routing,
|
||||||
|
* so when a hash-style link lands here we translate it to the real route once
|
||||||
|
* on mount. No-op for ordinary in-page anchors (only known aliases redirect).
|
||||||
|
*/
|
||||||
|
export default function HashRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const target = resolveHashAlias(window.location.hash);
|
||||||
|
if (target && window.location.pathname !== target) {
|
||||||
|
router.replace(target);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import VBtn from '@/components/vyndr/VBtn';
|
||||||
|
|
||||||
|
/** Interactive CTAs for the 404 (kept client-side so the page stays server). */
|
||||||
|
export default function NotFoundActions() {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<VBtn variant="primary" onClick={() => router.push('/dashboard')}>← Back to the Slate</VBtn>
|
||||||
|
<VBtn variant="outline" onClick={() => router.push('/ledger')}>Open the Ledger</VBtn>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import SectionHead from '@/components/vyndr/SectionHead';
|
||||||
|
import { Wordmark } from '@/components/vyndr';
|
||||||
|
|
||||||
|
type RouteStubProps = {
|
||||||
|
/** Page name, e.g. "Terminal". */
|
||||||
|
title: string;
|
||||||
|
/** When real content lands, e.g. "SESSION 35". */
|
||||||
|
arriving: string;
|
||||||
|
/** One-line system-voice description of what's coming. */
|
||||||
|
blurb?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for a VYNDR 2.0 route whose real screen ships in a later session
|
||||||
|
* (§C.3.5). Uses the design system + system language so a "coming soon" still
|
||||||
|
* feels intentional, never a blank 404.
|
||||||
|
*/
|
||||||
|
export default function RouteStub({ title, arriving, blurb }: RouteStubProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="scanlines"
|
||||||
|
style={{
|
||||||
|
minHeight: 'calc(100vh - 200px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 18,
|
||||||
|
padding: '40px 24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: 'var(--bg-0)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wordmark size="md" cursor beta />
|
||||||
|
<SectionHead accent="var(--g-c)">▚ ROUTE UNDER CONSTRUCTION · {arriving}</SectionHead>
|
||||||
|
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)', maxWidth: 420, lineHeight: 1.6 }}>
|
||||||
|
{blurb || 'This surface is being built. The signal is live; the screen lands soon.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* ============================================================
|
||||||
|
VYNDR 2.0 — app-shell routing config (§6, §12).
|
||||||
|
Plain CommonJS so the client AuthGate/HashRedirect import it
|
||||||
|
(allowJs) AND the Jest suite requires it directly (no transform).
|
||||||
|
|
||||||
|
AUTH NOTE: our auth is client-side Supabase (session in
|
||||||
|
localStorage via @supabase/supabase-js), and the existing
|
||||||
|
middleware.ts is locale-only. A Next server middleware can't read
|
||||||
|
that session, so the gate is enforced CLIENT-side by <AuthGate>.
|
||||||
|
This file is the single source of truth for which routes gate.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Gated — require an authenticated user. Deliberately narrower than the
|
||||||
|
prototype's GATED set: the prototype also gated dashboard + scan, but those
|
||||||
|
are OUR free-scan acquisition funnel (anon/free users get 5 reads), so
|
||||||
|
gating them would be a monetization regression. We gate only the genuinely
|
||||||
|
personal surfaces (a user's own ledger, bets, account, alerts). */
|
||||||
|
const GATED_ROUTES = [
|
||||||
|
'/ledger',
|
||||||
|
'/tracker',
|
||||||
|
'/account',
|
||||||
|
'/profile',
|
||||||
|
'/settings',
|
||||||
|
'/notifications',
|
||||||
|
'/invite',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Open — reachable without auth (marketing + the free funnel + auth itself). */
|
||||||
|
const OPEN_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/dashboard',
|
||||||
|
'/slate',
|
||||||
|
'/scan',
|
||||||
|
'/terminal',
|
||||||
|
'/compare',
|
||||||
|
'/game',
|
||||||
|
'/pricing',
|
||||||
|
'/blog',
|
||||||
|
'/article',
|
||||||
|
'/responsible-gambling',
|
||||||
|
'/help',
|
||||||
|
'/about',
|
||||||
|
'/terms',
|
||||||
|
'/privacy',
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/auth',
|
||||||
|
'/forgot-password',
|
||||||
|
'/verify',
|
||||||
|
'/welcome',
|
||||||
|
'/offline',
|
||||||
|
'/upgrade',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Hash deep-link aliases (§C.3.4). The prototype was a single HTML file using
|
||||||
|
#scan / #terminal; we keep Next file-based routing and treat these hashes as
|
||||||
|
redirects so old share links and PWA shortcuts still resolve. */
|
||||||
|
const HASH_ALIASES = {
|
||||||
|
'#slate': '/dashboard',
|
||||||
|
'#dashboard': '/dashboard',
|
||||||
|
'#scan': '/scan',
|
||||||
|
'#terminal': '/terminal',
|
||||||
|
'#compare': '/compare',
|
||||||
|
'#ledger': '/ledger',
|
||||||
|
'#tracker': '/tracker',
|
||||||
|
'#account': '/account',
|
||||||
|
'#pricing': '/pricing',
|
||||||
|
'#blog': '/blog',
|
||||||
|
'#invite': '/invite',
|
||||||
|
'#notifications': '/notifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when `pathname` falls under a gated route (exact or nested). */
|
||||||
|
function isGatedRoute(pathname) {
|
||||||
|
if (!pathname) return false;
|
||||||
|
return GATED_ROUTES.some((r) => pathname === r || pathname.startsWith(r + '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a window.location.hash (e.g. "#scan") to a real route, or null. */
|
||||||
|
function resolveHashAlias(hash) {
|
||||||
|
if (!hash) return null;
|
||||||
|
return HASH_ALIASES[hash] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GATED_ROUTES,
|
||||||
|
OPEN_ROUTES,
|
||||||
|
HASH_ALIASES,
|
||||||
|
isGatedRoute,
|
||||||
|
resolveHashAlias,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user