import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; // ============================================================================ // Route types // ============================================================================ type Route = | { page: 'welcome' } | { page: 'home' } | { page: 'events' } | { page: 'create-event' } | { page: 'event-detail'; eventId: string } | { page: 'update-event'; eventId: string } | { page: 'invite'; eventId: string } | { page: 'participants'; eventId: string } | { page: 'meeting-points'; eventId: string } | { page: 'profile' } | { page: 'edit-profile' } | { page: 'friends' } | { page: 'share-profile' } | { page: 'connect' } | { page: 'user-profile'; userId: string } | { page: 'settings' } | { page: 'login' }; export type { Route }; export interface RouteParams { eventId?: string; userId?: string; } // ============================================================================ // Path parsing & generation // ============================================================================ /** * Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so * they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that * was never encoded) is returned verbatim rather than throwing — the captured * text IS the id in that case. */ function decodeSegment(segment: string): string { try { return decodeURIComponent(segment); } catch { return segment; } } export function parsePath(pathname: string): Route { const path = pathname.replace(/\/+$/, '') || '/'; if (path === '/' || path === '') return { page: 'welcome' }; if (path === '/home') return { page: 'home' }; if (path === '/events') return { page: 'events' }; if (path === '/events/new') return { page: 'create-event' }; if (path === '/login') return { page: 'login' }; if (path === '/settings') return { page: 'settings' }; if (path === '/profile') return { page: 'profile' }; if (path === '/profile/edit') return { page: 'edit-profile' }; if (path === '/profile/friends') return { page: 'friends' }; if (path === '/profile/share') return { page: 'share-profile' }; if (path === '/profile/connect') return { page: 'connect' }; // /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it // holds no `/`; the sub-route is matched from the tail, and a raw (unencoded) // IRI still parses as the whole remainder. An ENCODED id may itself contain a // `/edit` segment without being read as a sub-route, which is why encoding is // mandatory on every navigation. const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/); if (eventMatch) { const eventId = decodeSegment(eventMatch[1]!); const sub = eventMatch[2]; if (!sub) return { page: 'event-detail', eventId }; if (sub === 'edit') return { page: 'update-event', eventId }; if (sub === 'invite') return { page: 'invite', eventId }; if (sub === 'participants') return { page: 'participants', eventId }; if (sub === 'meeting-points') return { page: 'meeting-points', eventId }; } // /users/:id — an absolute actor URI, encoded for the same reason. const userMatch = path.match(/^\/users\/(.+)$/); if (userMatch) { return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) }; } return { page: 'welcome' }; } export function routeToPath(route: Route): string { switch (route.page) { case 'welcome': return '/'; case 'home': return '/home'; case 'events': return '/events'; case 'create-event': return '/events/new'; case 'login': return '/login'; // Ids are absolute IRIs: they MUST be percent-encoded into a single path // segment, or the `/` and `:` inside them are read as route structure and // navigation falls through to the welcome page. `parsePath` decodes them. case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`; case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`; case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`; case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`; case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`; case 'profile': return '/profile'; case 'edit-profile': return '/profile/edit'; case 'friends': return '/profile/friends'; case 'share-profile': return '/profile/share'; case 'connect': return '/profile/connect'; case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`; case 'settings': return '/settings'; } } /** * Build a path to an event sub-route, encoding the id. Screens navigate by path * rather than by `Route`, so this is the one place that keeps a raw IRI out of * the URL structure. */ export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string { const base = `/events/${encodeURIComponent(eventId)}`; return sub ? `${base}/${sub}` : base; } /** Build a path to a user profile, encoding the actor URI. */ export function userPath(userId: string): string { return `/users/${encodeURIComponent(userId)}`; } // ============================================================================ // Router context // ============================================================================ interface RouterContextValue { route: Route; navigate: (path: string) => void; goBack: () => void; params: RouteParams; } const RouterContext = createContext(null); export function RouterProvider({ children }: { children: React.ReactNode }) { const [route, setRoute] = useState(() => parsePath(window.location.pathname)); useEffect(() => { const handlePopState = () => { setRoute(parsePath(window.location.pathname)); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); const navigate = useCallback((path: string) => { window.history.pushState(null, '', path); setRoute(parsePath(path)); }, []); const goBack = useCallback(() => { window.history.back(); }, []); const params: RouteParams = {}; if ('eventId' in route) params.eventId = route.eventId; if ('userId' in route) params.userId = route.userId; return ( {children} ); } // ============================================================================ // Hooks // ============================================================================ export function useRouter() { const context = useContext(RouterContext); if (!context) throw new Error('useRouter must be used within a RouterProvider'); return context; } export function useNavigate() { return useRouter().navigate; } export function useGoBack() { return useRouter().goBack; } export function useParams(): RouteParams { return useRouter().params; }