/** * usePageDataLoader Hook * * Unified hook for loading project and page data in presentation components. * Used by both RuntimePresentation (public) and constructor.tsx (authenticated). * * Features: * - Loads project by slug or ID * - Loads pages filtered by environment * - Sorts pages by sort_order * - Handles loading and error states * - Supports reloading with page preservation */ import { useState, useCallback, useEffect, useMemo } from 'react'; import axios from 'axios'; import { logger } from '../lib/logger'; import type { RuntimeProject, RuntimePage } from '../types/runtime'; /** * Configuration for the page data loader */ export interface UsePageDataLoaderOptions { /** Project ID for authenticated mode (constructor) */ projectId?: string; /** Project slug for public mode (runtime) */ projectSlug?: string; /** Environment to filter pages by */ environment: 'dev' | 'stage' | 'production'; /** Whether the data loading should be enabled */ enabled?: boolean; /** Custom API headers (e.g., for runtime environment context) */ apiHeaders?: Record; /** Initial page ID from route (for constructor) */ initialPageId?: string; } /** * Result of the page data loader */ export interface UsePageDataLoaderResult { /** Loaded project data */ project: RuntimeProject | null; /** Loaded and filtered pages */ pages: RuntimePage[]; /** Whether data is currently loading */ isLoading: boolean; /** Error message if loading failed */ error: string; /** Reload the data (optionally preserving current page selection) */ reload: (preservePageId?: string) => Promise; /** Initially selected page ID */ initialPageId: string; } /** * Extract rows from API response */ const getRows = (response: unknown): unknown[] => { const data = response as { data?: { rows?: unknown[] } }; return Array.isArray(data?.data?.rows) ? data.data.rows : []; }; /** * Hook for loading project and page data. * * @example * // Runtime mode (public presentation) * const { project, pages, isLoading, error } = usePageDataLoader({ * projectSlug: 'my-project', * environment: 'production', * }); * * @example * // Constructor mode (authenticated) * const { project, pages, isLoading, error, reload } = usePageDataLoader({ * projectId: 'uuid-here', * environment: 'dev', * enabled: isAuthReady, * }); */ export function usePageDataLoader({ projectId, projectSlug, environment, enabled = true, apiHeaders = {}, initialPageId: initialPageIdFromProps = '', }: UsePageDataLoaderOptions): UsePageDataLoaderResult { const [project, setProject] = useState(null); const [pages, setPages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [selectedInitialPageId, setSelectedInitialPageId] = useState(''); // Memoize API config to prevent unnecessary reloads const apiConfig = useMemo( () => ({ headers: apiHeaders, }), // Serialize headers for comparison // eslint-disable-next-line react-hooks/exhaustive-deps [JSON.stringify(apiHeaders)], ); /** * Load project and pages data */ const loadData = useCallback( async (preservePageId?: string) => { // Need either projectId or projectSlug if (!projectId && !projectSlug) { setError('No project identifier provided.'); setIsLoading(false); return; } if (!enabled) { return; } try { setIsLoading(true); setError(''); let foundProject: RuntimeProject | null = null; // Load by ID (constructor mode) if (projectId) { const projectResponse = await axios.get( `/projects/${projectId}`, apiConfig, ); foundProject = projectResponse.data; } // Load by slug (runtime mode) else if (projectSlug) { const projectsResponse = await axios.get('/projects', { ...apiConfig, params: { slug: projectSlug }, }); const projectRows = getRows(projectsResponse) as RuntimeProject[]; foundProject = projectRows.find((p) => p.slug === projectSlug) || null; if (!foundProject) { setError(`Project "${projectSlug}" not found.`); setIsLoading(false); return; } } if (!foundProject) { setError('Project not found.'); setIsLoading(false); return; } setProject(foundProject); // Load pages for this project const pagesParams: Record = { project: foundProject.id, }; // For constructor mode, also filter by environment in params if (projectId) { pagesParams.environment = environment; pagesParams.limit = '500'; pagesParams.sort = 'asc'; pagesParams.field = 'sort_order'; } const pagesResponse = await axios.get('/tour_pages', { ...apiConfig, params: pagesParams, }); let pageRows = getRows(pagesResponse) as RuntimePage[]; // For runtime mode, filter by environment client-side // (backend may not have environment header support) if (projectSlug) { pageRows = pageRows.filter((p) => p.environment === environment); } // Sort by sort_order pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); setPages(pageRows); // Determine initial page const preservedPageExists = preservePageId && pageRows.some((p) => p.id === preservePageId); const defaultPageId = preservedPageExists ? preservePageId : initialPageIdFromProps || (pageRows.length > 0 ? pageRows[0].id : ''); setSelectedInitialPageId(defaultPageId); } catch (err: unknown) { const axiosError = err as { response?: { status?: number; data?: { message?: string } }; message?: string; }; // Handle authentication errors if (axiosError?.response?.status === 401) { setError('Your session has expired. Please sign in again.'); logger.error('Unauthorized request during data load'); return; } const message = axiosError?.response?.data?.message || axiosError?.message || 'Failed to load presentation data.'; logger.error( 'Failed to load page data:', err instanceof Error ? err : { error: err }, ); setError(message); setPages([]); } finally { setIsLoading(false); } }, [ projectId, projectSlug, environment, enabled, apiConfig, initialPageIdFromProps, ], ); // Initial load useEffect(() => { loadData(); }, [loadData]); return { project, pages, isLoading, error, reload: loadData, initialPageId: selectedInitialPageId, }; }