391 lines
11 KiB
TypeScript
391 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';
|
|
import { useReverseVideoPolling } from './useReverseVideoPolling';
|
|
|
|
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;
|
|
/** 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>;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Polling hook for reverse video generation status
|
|
const { startPolling } = useReverseVideoPolling({
|
|
onReload,
|
|
pollInterval: 2000,
|
|
maxPollDuration: 30000,
|
|
});
|
|
|
|
/**
|
|
* Extract storage keys for elements that need reverse video generation.
|
|
* These are elements with:
|
|
* - transitionVideoUrl set (has a transition video)
|
|
* - transitionReverseMode is 'auto_reverse' or not set (default)
|
|
* - reverseVideoUrl is NOT set (not yet generated)
|
|
*/
|
|
const getPendingReverseVideoKeys = useCallback(
|
|
(elementsToCheck: CanvasElement[]): string[] => {
|
|
return elementsToCheck
|
|
.filter((el) => {
|
|
const hasTransition = Boolean(el.transitionVideoUrl);
|
|
const isAutoReverse = el.transitionReverseMode !== 'separate_video'; // auto_reverse is default
|
|
const needsGeneration = !el.reverseVideoUrl;
|
|
return hasTransition && isAutoReverse && needsGeneration;
|
|
})
|
|
.map((el) => el.transitionVideoUrl as string)
|
|
.filter(Boolean);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const saveConstructor = useCallback(async () => {
|
|
if (!activePageId) {
|
|
onError?.('Select a page before saving.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
|
|
// Capture pending reverse video keys BEFORE save
|
|
// These are elements that will trigger async reversed video generation
|
|
const pendingReverseKeys = getPendingReverseVideoKeys(elements);
|
|
|
|
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);
|
|
|
|
// Start polling for reverse video generation if there are pending keys
|
|
// This will automatically reload page data when all videos are ready
|
|
if (pendingReverseKeys.length > 0) {
|
|
logger.info('[SAVE] Starting reverse video polling', {
|
|
pendingCount: pendingReverseKeys.length,
|
|
});
|
|
startPolling(pendingReverseKeys, 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,
|
|
getPendingReverseVideoKeys,
|
|
startPolling,
|
|
]);
|
|
|
|
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,
|
|
],
|
|
);
|
|
|
|
return {
|
|
isSaving,
|
|
isSavingToStage,
|
|
isCreatingPage,
|
|
saveConstructor,
|
|
saveToStage,
|
|
createPage,
|
|
};
|
|
}
|
|
|
|
export default useConstructorPageActions;
|