diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 063d910a..b96426bf 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -285,6 +285,7 @@ frontend/src/ - Handles tooltip positioning - Manages route changes - How it works page (`/how-it-works`) renders full-bleed (no `px-3 py-3` padding on `
`) + - Calls `normalizeLocation(window)` from `src/lib/normalizePath.js` during app startup to normalize direct/path-based URLs for the hash router - **Navigation**: `src/components/Navbar.svelte` - Top navigation bar - Auth button integration @@ -300,6 +301,13 @@ frontend/src/ - **How it works** (bottom pinned area, above Submit Contribution) - Links to `/how-it-works` - **Profile** - User profile and submissions +### Hash Route Normalization +The portal uses `svelte-spa-router`, so app routes must be represented as hash URLs such as `/#/testnets`. `src/App.svelte` imports `normalizeLocation` from `src/lib/normalizePath.js` and invokes `normalizeLocation(window)` once at initial app load. + +`normalizeLocation` reads `window.location.pathname`, `window.location.search`, and `window.location.hash`. If a hash is already present, the path is `/`, the path looks like a static file, or the path starts with a reserved server/static prefix (`/api`, `/oauth`, `/static`, `/assets`, `/media`), it does nothing. Otherwise it rewrites the current URL with `window.history.replaceState({}, '', '/#' + pathname + search)`, so `/metrics?range=30d` becomes `/#/metrics?range=30d`. + +Use this normalization when debugging direct links, copied links, refreshes, or server-served deep links that arrive as plain paths. New external entry points to SPA routes should either emit hash URLs directly (`/#/route`) or be normalized by invoking `normalizeLocation(window)` before the router resolves the page. + ### Routes/Pages All routes are defined in `src/App.svelte`: ```javascript @@ -807,4 +815,4 @@ $location // reactive store with current path - Pages go in `src/routes/` - Reusable components in `src/components/` - API functions in `src/lib/api.js` -- New API endpoints need both frontend (api.js) and backend implementation \ No newline at end of file +- New API endpoints need both frontend (api.js) and backend implementation diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6ddfa3f1..1a21b01c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,7 @@ import { location } from 'svelte-spa-router'; import { resetPageMeta } from './lib/meta.js'; import { authState, verifyAuth } from './lib/auth.js'; + import { normalizeLocation } from './lib/normalizePath.js'; // Early OAuth result detection — runs before routes mount. // Backend redirects here with ?oauth_platform=X&oauth_verified=true/false&oauth_error=... @@ -49,22 +50,12 @@ } } - // The portal uses hash routing. Normalize pasted/local links such as - // /claim/poap/:token so mint links work on localhost and 127.0.0.1. - { - const path = window.location.pathname; - const shouldNormalize = - !window.location.hash && - (path.startsWith('/claim/poap/') || path.startsWith('/community/poaps/')); - - if (shouldNormalize) { - window.history.replaceState( - {}, - '', - `/#${path}${window.location.search || ''}` - ); - } - } + // The portal uses hash routing. Direct/path-based links (sidebar hrefs + // opened in a new tab, refreshes of a path route, shared or indexed links) + // arrive without a hash and would otherwise 404. Rewrite any such path into + // its hash equivalent so the router can resolve it; unknown paths still + // fall through to the router's own NotFound view. + normalizeLocation(window); // State for sidebar toggle on mobile and collapse on desktop let sidebarOpen = $state(false); diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index 0e7c0fb7..fddd7a12 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -187,7 +187,7 @@ {#if !collapsed && getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -196,7 +196,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -240,7 +240,7 @@ {#if !collapsed && getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -249,7 +249,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -258,7 +258,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -293,7 +293,7 @@ {#if !collapsed && getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -302,7 +302,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -311,7 +311,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -320,7 +320,7 @@ Participants { e.preventDefault(); navigate('/validators/wall-of-shame'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -329,7 +329,7 @@ Wall of Shame { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -364,7 +364,7 @@ {#if !collapsed && getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -373,7 +373,7 @@ Contributions { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -382,7 +382,7 @@ Leaderboard { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -418,7 +418,7 @@ {#if !collapsed && getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -428,7 +428,7 @@ {#if canAccessDiscordXP} { e.preventDefault(); navigate('/stewards/discord-xp'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/discord-xp') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -439,7 +439,7 @@ {/if} {#if canAccessManageUsers} { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -635,7 +635,7 @@ {#if getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -644,7 +644,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -677,7 +677,7 @@ {#if getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -686,7 +686,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -695,7 +695,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -723,7 +723,7 @@ {#if getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -732,7 +732,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -741,7 +741,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -750,7 +750,7 @@ Participants { e.preventDefault(); navigate('/validators/wall-of-shame'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -759,7 +759,7 @@ Wall of Shame { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -787,7 +787,7 @@ {#if getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -796,7 +796,7 @@ Contributions { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -805,7 +805,7 @@ Leaderboard { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -833,7 +833,7 @@ {#if getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -843,7 +843,7 @@ {#if canAccessDiscordXP} { e.preventDefault(); navigate('/stewards/discord-xp'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/discord-xp') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -854,7 +854,7 @@ {/if} {#if canAccessManageUsers} { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' diff --git a/frontend/src/lib/normalizePath.js b/frontend/src/lib/normalizePath.js new file mode 100644 index 00000000..865c01d2 --- /dev/null +++ b/frontend/src/lib/normalizePath.js @@ -0,0 +1,48 @@ +/** + * Path-to-hash route normalization for the hash-based portal router. + * + * The portal uses `svelte-spa-router`, which resolves routes from the URL hash + * (e.g. `/#/testnets`). Several entry points still produce plain path URLs + * (e.g. `/testnets`, `/metrics`): sidebar `href` attributes, links opened in a + * new tab, refreshes of a path-based route, and shared/indexed links. When such + * a URL is opened directly, the hash is empty, the router has nothing to match, + * and the user lands on a 404 / NotFound view. + * + * This module decides whether a plain path URL should be rewritten into its + * hash equivalent so the router can resolve it. It is deliberately + * route-agnostic: any unknown path is forwarded to the router, which renders + * its own NotFound for genuinely missing routes. Static assets and known + * server-handled prefixes (OAuth, API) are left untouched. + */ + +const RESERVED_PREFIXES = ['/api', '/oauth', '/static', '/assets', '/media']; + +function looksLikeStaticFile(pathname) { + const lastSegment = pathname.split('/').pop() || ''; + return lastSegment.includes('.'); +} + +export function computeNormalizedUrl(location) { + const pathname = location.pathname || '/'; + const hash = location.hash || ''; + const search = location.search || ''; + + if (hash) return null; + if (pathname === '/' || pathname === '') return null; + if (RESERVED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null; + if (looksLikeStaticFile(pathname)) return null; + + return `/#${pathname}${search}`; +} + +export function normalizeLocation( + win = typeof window !== 'undefined' ? window : undefined +) { + if (!win || !win.location || !win.history) return false; + + const target = computeNormalizedUrl(win.location); + if (!target) return false; + + win.history.replaceState({}, '', target); + return true; +} diff --git a/frontend/src/tests/normalizePath.test.js b/frontend/src/tests/normalizePath.test.js new file mode 100644 index 00000000..5c1059a0 --- /dev/null +++ b/frontend/src/tests/normalizePath.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeNormalizedUrl, + normalizeLocation, +} from '../lib/normalizePath.js'; + +describe('computeNormalizedUrl', () => { + it('rewrites a plain path route into its hash equivalent', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '', search: '' })) + .toBe('/#/testnets'); + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '' })) + .toBe('/#/metrics'); + }); + + it('preserves the query string when rewriting', () => { + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '?range=30d' })) + .toBe('/#/metrics?range=30d'); + }); + + it('rewrites nested path routes', () => { + expect(computeNormalizedUrl({ pathname: '/builders/leaderboard', hash: '', search: '' })) + .toBe('/#/builders/leaderboard'); + }); + + it('returns null when a hash is already present', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '#/testnets', search: '' })) + .toBeNull(); + expect(computeNormalizedUrl({ pathname: '/', hash: '#/metrics', search: '' })) + .toBeNull(); + }); + + it('returns null for the root path', () => { + expect(computeNormalizedUrl({ pathname: '/', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '', hash: '', search: '' })).toBeNull(); + }); + + it('leaves reserved or server-handled prefixes untouched', () => { + expect(computeNormalizedUrl({ pathname: '/api/users', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/oauth/callback', hash: '', search: '?code=x' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/assets/app.js', hash: '', search: '' })).toBeNull(); + }); + + it('leaves static file requests untouched', () => { + expect(computeNormalizedUrl({ pathname: '/favicon.ico', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/robots.txt', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/sitemap.xml', hash: '', search: '' })).toBeNull(); + }); + + it('tolerates missing hash and search fields', () => { + expect(computeNormalizedUrl({ pathname: '/leaderboard' })).toBe('/#/leaderboard'); + }); +}); + +describe('normalizeLocation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('rewrites the URL via history.replaceState when normalization is needed', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/testnets', hash: '', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(true); + expect(replaceState).toHaveBeenCalledWith({}, '', '/#/testnets'); + }); + + it('does nothing when a hash route is already present', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/', hash: '#/testnets', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(false); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it('returns false safely when window or history is unavailable', () => { + expect(normalizeLocation(undefined)).toBe(false); + expect(normalizeLocation({})).toBe(false); + expect(normalizeLocation({ location: { pathname: '/testnets' } })).toBe(false); + }); +});