254 lines
7.0 KiB
TypeScript
254 lines
7.0 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
/** 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<void>;
|
|
/** 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<RuntimeProject | null>(null);
|
|
const [pages, setPages] = useState<RuntimePage[]>([]);
|
|
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<string, string> = {
|
|
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,
|
|
};
|
|
}
|