406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
/**
|
|
* useConstructorPageActions Hook
|
|
*
|
|
* Handles page create/save/publish operations in the constructor.
|
|
* Manages async state and API calls for page operations.
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import axios from 'axios';
|
|
import type { CanvasElement, PageBackgroundState } from '../types/constructor';
|
|
import { createLocalId } from '../lib/elementDefaults';
|
|
import { parseJsonObject } from '../lib/parseJson';
|
|
import { logger } from '../lib/logger';
|
|
|
|
interface TourPage {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
sort_order?: number;
|
|
environment?: string;
|
|
source_key?: string;
|
|
requires_auth?: boolean;
|
|
ui_schema_json?: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_audio_url?: string;
|
|
background_loop?: boolean;
|
|
// Background video playback settings
|
|
background_video_autoplay?: boolean;
|
|
background_video_loop?: boolean;
|
|
background_video_muted?: boolean;
|
|
background_video_start_time?: number | null;
|
|
background_video_end_time?: number | null;
|
|
}
|
|
|
|
interface Project {
|
|
id?: string;
|
|
name?: string;
|
|
design_width?: number;
|
|
design_height?: number;
|
|
}
|
|
|
|
interface UseConstructorPageActionsOptions {
|
|
/** Current project ID */
|
|
projectId: string;
|
|
/** Current project (for design dimensions) */
|
|
project?: Project | null;
|
|
/** Array of all pages */
|
|
pages: TourPage[];
|
|
/** Currently active page */
|
|
activePage: TourPage | null;
|
|
/** Current active page ID */
|
|
activePageId: string;
|
|
/** Current elements array */
|
|
elements: CanvasElement[];
|
|
/** Consolidated page background state */
|
|
pageBackground: PageBackgroundState;
|
|
/** Callback to reload data after operations */
|
|
onReload: (preservePageId?: string) => Promise<void>;
|
|
/** Callback to set active page ID */
|
|
onSetActivePageId: (pageId: string) => void;
|
|
/** Callback to set menu open state */
|
|
onSetMenuOpen?: (isOpen: boolean) => void;
|
|
/** Callback for error messages */
|
|
onError?: (message: string) => void;
|
|
/** Callback for success messages */
|
|
onSuccess?: (message: string) => void;
|
|
}
|
|
|
|
interface UseConstructorPageActionsResult {
|
|
/** Whether save operation is in progress */
|
|
isSaving: boolean;
|
|
/** Whether save-to-stage operation is in progress */
|
|
isSavingToStage: boolean;
|
|
/** Whether page creation is in progress */
|
|
isCreatingPage: boolean;
|
|
/** Whether transition creation is in progress */
|
|
isCreatingTransition: boolean;
|
|
/** Save current constructor state */
|
|
saveConstructor: () => Promise<void>;
|
|
/** Save dev content to stage environment */
|
|
saveToStage: () => Promise<void>;
|
|
/** Create a new page with the given name and slug */
|
|
createPage: (pageName: string, slug: string) => Promise<void>;
|
|
/** Create a transition (legacy - transitions are now stored on elements) */
|
|
createTransition: (params: {
|
|
name?: string;
|
|
videoUrl: string;
|
|
supportsReverse?: boolean;
|
|
durationSec?: number;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing constructor page operations.
|
|
*
|
|
* @example
|
|
* const {
|
|
* isSaving,
|
|
* saveConstructor,
|
|
* createPage,
|
|
* isCreatingPage,
|
|
* } = useConstructorPageActions({
|
|
* projectId,
|
|
* pages,
|
|
* activePage,
|
|
* activePageId,
|
|
* elements,
|
|
* pageBackground: background,
|
|
* onReload: loadData,
|
|
* onSetActivePageId: setActivePageId,
|
|
* onError: setErrorMessage,
|
|
* onSuccess: setSuccessMessage,
|
|
* });
|
|
*/
|
|
export function useConstructorPageActions({
|
|
projectId,
|
|
project,
|
|
pages,
|
|
activePage,
|
|
activePageId,
|
|
elements,
|
|
pageBackground,
|
|
onReload,
|
|
onSetActivePageId,
|
|
onSetMenuOpen,
|
|
onError,
|
|
onSuccess,
|
|
}: UseConstructorPageActionsOptions): UseConstructorPageActionsResult {
|
|
// Destructure pageBackground for backward compatibility in the save logic
|
|
const {
|
|
imageUrl: backgroundImageUrl,
|
|
videoUrl: backgroundVideoUrl,
|
|
audioUrl: backgroundAudioUrl,
|
|
videoSettings: {
|
|
autoplay: backgroundVideoAutoplay,
|
|
loop: backgroundVideoLoop,
|
|
muted: backgroundVideoMuted,
|
|
startTime: backgroundVideoStartTime,
|
|
endTime: backgroundVideoEndTime,
|
|
},
|
|
} = pageBackground;
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
|
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
|
|
|
const saveConstructor = useCallback(async () => {
|
|
if (!activePageId) {
|
|
onError?.('Select a page before saving.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
|
|
const existingSchema = parseJsonObject<Record<string, any>>(
|
|
activePage?.ui_schema_json,
|
|
{},
|
|
);
|
|
const schemaToSave = {
|
|
...existingSchema,
|
|
elements,
|
|
};
|
|
|
|
await axios.put(`/tour_pages/${activePageId}`, {
|
|
id: activePageId,
|
|
data: {
|
|
environment: activePage?.environment,
|
|
source_key: activePage?.source_key,
|
|
name: activePage?.name,
|
|
slug: activePage?.slug,
|
|
sort_order: activePage?.sort_order,
|
|
requires_auth: activePage?.requires_auth,
|
|
ui_schema_json: schemaToSave,
|
|
background_image_url: backgroundImageUrl,
|
|
background_video_url: backgroundVideoUrl,
|
|
background_audio_url: backgroundAudioUrl,
|
|
background_loop: Boolean(backgroundAudioUrl),
|
|
background_video_autoplay: backgroundVideoAutoplay,
|
|
background_video_loop: backgroundVideoLoop,
|
|
background_video_muted: backgroundVideoMuted,
|
|
background_video_start_time: backgroundVideoStartTime,
|
|
background_video_end_time: backgroundVideoEndTime,
|
|
// Copy project design dimensions to page for presentation isolation
|
|
design_width: project?.design_width ?? null,
|
|
design_height: project?.design_height ?? null,
|
|
},
|
|
});
|
|
|
|
onSuccess?.(
|
|
'Constructor settings saved. Element positions are stored in percentages.',
|
|
);
|
|
await onReload(activePageId);
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to save constructor changes.';
|
|
logger.error(
|
|
'Failed to save constructor changes:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
onError?.(message);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
activePage?.name,
|
|
activePage?.requires_auth,
|
|
activePage?.slug,
|
|
activePage?.sort_order,
|
|
activePage?.source_key,
|
|
activePage?.ui_schema_json,
|
|
activePageId,
|
|
pageBackground,
|
|
elements,
|
|
project?.design_width,
|
|
project?.design_height,
|
|
onError,
|
|
onReload,
|
|
onSuccess,
|
|
]);
|
|
|
|
const saveToStage = useCallback(async () => {
|
|
if (!projectId) {
|
|
onError?.('Project ID is required to save to stage.');
|
|
return;
|
|
}
|
|
|
|
await saveConstructor();
|
|
|
|
try {
|
|
setIsSavingToStage(true);
|
|
await axios.post('/publish/save-to-stage', { projectId });
|
|
onSuccess?.('Saved to stage.');
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to save to stage.';
|
|
logger.error(
|
|
'Failed to save to stage:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
onError?.(message);
|
|
} finally {
|
|
setIsSavingToStage(false);
|
|
}
|
|
}, [projectId, saveConstructor, onError, onSuccess]);
|
|
|
|
const createPage = useCallback(async (pageName: string, slug: string) => {
|
|
if (!projectId) {
|
|
onError?.('Project is required.');
|
|
return;
|
|
}
|
|
|
|
if (!pageName.trim()) {
|
|
onError?.('Page name is required.');
|
|
return;
|
|
}
|
|
|
|
if (!slug.trim()) {
|
|
onError?.('Page slug is required.');
|
|
return;
|
|
}
|
|
|
|
const maxSortOrder = Math.max(
|
|
0,
|
|
...pages.map((item) => Number(item.sort_order || 0)),
|
|
);
|
|
|
|
const payload = {
|
|
project: projectId,
|
|
environment: activePage?.environment || 'dev',
|
|
source_key: '',
|
|
name: pageName.trim(),
|
|
slug: slug.trim(),
|
|
sort_order: maxSortOrder + 1,
|
|
background_image_url: '',
|
|
background_video_url: '',
|
|
background_audio_url: '',
|
|
background_loop: false,
|
|
requires_auth: false,
|
|
ui_schema_json: { elements: [] },
|
|
// Copy project design dimensions to new page
|
|
design_width: project?.design_width ?? null,
|
|
design_height: project?.design_height ?? null,
|
|
};
|
|
|
|
try {
|
|
setIsCreatingPage(true);
|
|
const response = await axios.post('/tour_pages', { data: payload });
|
|
const createdPage = response?.data;
|
|
|
|
await onReload();
|
|
|
|
if (createdPage?.id) {
|
|
onSetActivePageId(createdPage.id);
|
|
}
|
|
|
|
onSetMenuOpen?.(true);
|
|
onSuccess?.('New page created. You can now configure it in constructor.');
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to create page.';
|
|
logger.error(
|
|
'Failed to create page from constructor:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
onError?.(message);
|
|
} finally {
|
|
setIsCreatingPage(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
onError,
|
|
onReload,
|
|
onSetActivePageId,
|
|
onSetMenuOpen,
|
|
onSuccess,
|
|
pages,
|
|
project?.design_width,
|
|
project?.design_height,
|
|
projectId,
|
|
]);
|
|
|
|
const createTransition = useCallback(
|
|
async (params: {
|
|
name?: string;
|
|
videoUrl: string;
|
|
supportsReverse?: boolean;
|
|
durationSec?: number;
|
|
}) => {
|
|
if (!projectId) {
|
|
onError?.('Project is required.');
|
|
return;
|
|
}
|
|
|
|
const sanitizedVideoUrl = String(params.videoUrl || '').trim();
|
|
if (!sanitizedVideoUrl) {
|
|
onError?.('Select a transition video asset first.');
|
|
return;
|
|
}
|
|
|
|
if (!params.durationSec) {
|
|
onError?.(
|
|
'Could not resolve transition video duration yet. Please wait a moment and try again.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCreatingTransition(true);
|
|
|
|
// Transitions are now stored directly in navigation elements as transitionVideoUrl
|
|
// This method is kept for backwards compatibility but just shows a message
|
|
onSuccess?.(
|
|
'Transition video can be set directly on navigation elements.',
|
|
);
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to create transition.';
|
|
logger.error(
|
|
'Failed to create transition from constructor:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
onError?.(message);
|
|
} finally {
|
|
setIsCreatingTransition(false);
|
|
}
|
|
},
|
|
[projectId, onError, onSuccess],
|
|
);
|
|
|
|
return {
|
|
isSaving,
|
|
isSavingToStage,
|
|
isCreatingPage,
|
|
isCreatingTransition,
|
|
saveConstructor,
|
|
saveToStage,
|
|
createPage,
|
|
createTransition,
|
|
};
|
|
}
|
|
|
|
export default useConstructorPageActions;
|