import { mdiChartTimelineVariant, mdiClose, mdiFileDocumentPlus, mdiSwapHorizontal, mdiViewDashboard, mdiChevronDown, mdiChevronUp, mdiPencil, } from '@mdi/js'; import Icon from '@mdi/react'; import axios from 'axios'; import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import BaseButton from './BaseButton'; import CardBox from './CardBox'; import CardBoxModal from './CardBoxModal'; import SectionMain from './SectionMain'; import SectionTitleLineWithButton from './SectionTitleLineWithButton'; import { getPageTitle } from '../config'; import { hasPermission } from '../helpers/userPermissions'; import { useAppSelector, useAppDispatch } from '../stores/hooks'; import { logger } from '../lib/logger'; import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers'; import { toRoutePath, compareRoutes, getProjectId, getRows, } from '../lib/tourFlowHelpers'; import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice'; import { fetchByProjectAndEnv, upsertByProjectAndEnv, deleteByProjectAndEnv, selectByProjectAndEnv, selectIsLoading as selectTransitionSettingsLoading, } from '../stores/project_transition_settings/projectTransitionSettingsSlice'; import type { ProjectTransitionSettings, TransitionType, EasingFunction, } from '../types/transition'; import { entityToProjectSettings } from '../types/transition'; type TourPage = { id: string; name?: string; slug?: string; sort_order?: number; environment?: string; projectId?: string; project?: { id?: string } | string; }; type Transition = { id: string; name?: string; slug?: string; environment?: string; projectId?: string; createdAt?: string; project?: { id?: string } | string; }; type ProjectOption = { id: string; label: string; }; type ListEntry = { type: 'page' | 'transition'; id: string; title: string; routeLabel: string; description: string; parentPageId: string; }; const TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [ { value: '', label: 'Use Global Default' }, { value: 'fade', label: 'Fade' }, { value: 'none', label: 'None (instant)' }, ]; const EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [ { value: '', label: 'Use Global Default' }, { value: 'ease-in-out', label: 'Ease In-Out' }, { value: 'ease-in', label: 'Ease In' }, { value: 'ease-out', label: 'Ease Out' }, { value: 'linear', label: 'Linear' }, ]; const TourFlowManager = () => { const router = useRouter(); const dispatch = useAppDispatch(); const { currentUser } = useAppSelector((state) => state.auth); const globalDefaults = useAppSelector( (state) => state.global_transition_defaults.data, ); const routeProjectId = useMemo(() => { const value = router.query.projectId; if (Array.isArray(value)) return value[0] || ''; return String(value || ''); }, [router.query.projectId]); const [pages, setPages] = useState([]); const [transitions, setTransitions] = useState([]); const [projects, setProjects] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingTransition, setIsCreatingTransition] = useState(false); const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false); const [newPageName, setNewPageName] = useState(''); const [deletingId, setDeletingId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); // Edit page modal state const [isEditPageModalActive, setIsEditPageModalActive] = useState(false); const [editingPageId, setEditingPageId] = useState(''); const [editPageName, setEditPageName] = useState(''); const [editPageNameError, setEditPageNameError] = useState(''); const [isSavingPageName, setIsSavingPageName] = useState(false); // Use selector for current project's dev transition settings const projectTransitionSettingsEntity = useAppSelector((state) => selectedProjectId ? selectByProjectAndEnv(state, selectedProjectId, 'dev') : undefined, ); const isTransitionSettingsLoading = useAppSelector((state) => selectedProjectId ? selectTransitionSettingsLoading(state, selectedProjectId, 'dev') : false, ); // Project transition settings state const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] = useState(false); // Convert entity to camelCase for local form state const projectTransitionSettings = useMemo( () => entityToProjectSettings(projectTransitionSettingsEntity), [projectTransitionSettingsEntity], ); const [localTransitionType, setLocalTransitionType] = useState< TransitionType | '' >(''); const [localDurationMs, setLocalDurationMs] = useState(''); const [localEasing, setLocalEasing] = useState(''); const [localOverlayColor, setLocalOverlayColor] = useState(''); const [isSavingTransitionSettings, setIsSavingTransitionSettings] = useState(false); const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false); const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES'); const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS'); const canDeletePage = hasPermission(currentUser, 'DELETE_TOUR_PAGES'); const canDeleteTransition = hasPermission(currentUser, 'DELETE_TRANSITIONS'); const loadProjects = useCallback(async () => { const response = await axios.get('/projects/autocomplete?limit=100'); const projectOptions = Array.isArray(response?.data) ? response.data : []; setProjects(projectOptions); if (projectOptions.length === 0) { toast('Please create a project first', { type: 'info', position: 'bottom-center', }); router.replace('/projects/projects-new'); return ''; } if ( routeProjectId && projectOptions.some((item: ProjectOption) => item.id === routeProjectId) ) { setSelectedProjectId(routeProjectId); return routeProjectId; } if (projectOptions.length > 0) { setSelectedProjectId((prev) => prev || projectOptions[0].id); return projectOptions[0].id; } setSelectedProjectId(''); return ''; }, [routeProjectId, router]); const loadData = useCallback(async () => { try { setIsLoading(true); setErrorMessage(''); const projectId = selectedProjectId || (await loadProjects()); if (!projectId) { setPages([]); setTransitions([]); setIsLoading(false); return; } const pagesResponse = await axios.get( `/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`, ); setPages(getRows(pagesResponse)); setTransitions([]); } catch (error: unknown) { const axiosError = error as { response?: { data?: { message?: string } }; }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || 'Failed to load pages and transitions.', ); logger.error( 'Failed to load merged pages/transitions list:', error instanceof Error ? error : { error }, ); } finally { setIsLoading(false); } }, [loadProjects, selectedProjectId]); useEffect(() => { loadData(); }, [loadData]); // Fetch global transition defaults useEffect(() => { dispatch(fetchGlobalTransitionDefaults()); }, [dispatch]); // Load project transition settings when project changes useEffect(() => { if (!selectedProjectId) { setLocalTransitionType(''); setLocalDurationMs(''); setLocalEasing(''); setLocalOverlayColor(''); return; } // Dispatch fetch for dev environment settings dispatch( fetchByProjectAndEnv({ projectId: selectedProjectId, environment: 'dev', }), ); }, [selectedProjectId, dispatch]); // Sync local form state when store data changes useEffect(() => { setLocalTransitionType(projectTransitionSettings?.transitionType ?? ''); setLocalDurationMs(projectTransitionSettings?.durationMs ?? ''); setLocalEasing(projectTransitionSettings?.easing ?? ''); setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? ''); }, [projectTransitionSettings]); useEffect(() => { if (!selectedProjectId) return; if (routeProjectId && selectedProjectId === routeProjectId) return; if (routeProjectId) return; router.replace( { pathname: '/tour_pages/tour_pages-list', query: { projectId: selectedProjectId }, }, undefined, { shallow: true }, ); }, [routeProjectId, router, selectedProjectId]); const sortedPages = useMemo( () => [...pages].sort((first, second) => { const routeCompare = compareRoutes(first.slug, second.slug); if (routeCompare !== 0) return routeCompare; const orderCompare = (first.sort_order || 0) - (second.sort_order || 0); if (orderCompare !== 0) return orderCompare; return (first.name || '').localeCompare(second.name || ''); }), [pages], ); const sortedTransitions = useMemo( () => [...transitions].sort((first, second) => { const dateFirst = first.createdAt ? new Date(first.createdAt).getTime() : 0; const dateSecond = second.createdAt ? new Date(second.createdAt).getTime() : 0; if (dateFirst !== dateSecond) return dateFirst - dateSecond; return (first.name || first.slug || '').localeCompare( second.name || second.slug || '', ); }), [transitions], ); const activeProjectId = useMemo(() => { if (selectedProjectId) return selectedProjectId; const fromPages = sortedPages.find((item) => getProjectId(item)); if (fromPages) return getProjectId(fromPages); const fromTransitions = sortedTransitions.find((item) => getProjectId(item), ); if (fromTransitions) return getProjectId(fromTransitions); return projects[0]?.id || ''; }, [projects, selectedProjectId, sortedPages, sortedTransitions]); const targetEnvironment = useMemo( () => sortedPages[0]?.environment || sortedTransitions[0]?.environment || 'dev', [sortedPages, sortedTransitions], ); const pageSlugsInEnvironment = useMemo( () => new Set( pages .filter((item) => (item.environment || 'dev') === targetEnvironment) .map((item) => String(item.slug || '').trim()) .filter(Boolean), ), [pages, targetEnvironment], ); const nameValidationError = useMemo(() => { const name = newPageName.trim(); if (!name) return 'Page name is required.'; if (name.length > 255) return 'Page name must be 255 characters or less.'; return ''; }, [newPageName]); // Auto-generate unique slug from name const generatedSlug = useMemo(() => { const baseSlug = sanitizeSlug(newPageName) || 'page'; return buildUniqueSlug(baseSlug, pageSlugsInEnvironment); }, [newPageName, pageSlugsInEnvironment]); const editNameValidationError = useMemo(() => { const name = editPageName.trim(); if (!name) return 'Page name is required.'; if (name.length > 255) return 'Page name must be 255 characters or less.'; return ''; }, [editPageName]); const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]); const listEntries = useMemo(() => { const entries: ListEntry[] = []; sortedPages.forEach((page, index) => { const route = toRoutePath(page.slug); entries.push({ type: 'page', id: page.id, title: page.name || `Page ${index + 1}`, routeLabel: route, description: 'Page', parentPageId: page.id, }); const transition = sortedTransitions[index]; if (!transition) return; entries.push({ type: 'transition', id: transition.id, title: transition.name || transition.slug || `Transition ${index + 1}`, routeLabel: route, description: `Transition after ${route}`, parentPageId: page.id, }); }); return entries; }, [sortedPages, sortedTransitions]); const openConstructor = ( pageId: string, sourceId: string, sourceType: 'page' | 'transition', ) => { if (!activeProjectId) { router.push('/projects/projects-list'); return; } router.push({ pathname: '/constructor', query: { projectId: activeProjectId, pageId, sourceId, sourceType, }, }); }; const openCreatePageModal = () => { if (!activeProjectId) { setErrorMessage('Project not found. Please create a project first.'); return; } const suggestedName = `Page ${nextPageNumber}`; setNewPageName(suggestedName); setIsCreatePageModalActive(true); }; const handleNameChange = (value: string) => { setNewPageName(value); }; const closeCreatePageModal = () => { setIsCreatePageModalActive(false); setNewPageName(''); }; // Edit page modal handlers const openEditPageModal = (event: React.MouseEvent, page: TourPage) => { event.stopPropagation(); setEditingPageId(page.id); setEditPageName(page.name || ''); setEditPageNameError(''); setIsEditPageModalActive(true); }; const handleEditNameChange = (value: string) => { setEditPageName(value); setEditPageNameError(''); }; const closeEditPageModal = () => { setIsEditPageModalActive(false); setEditingPageId(''); setEditPageName(''); setEditPageNameError(''); }; const handleSavePageName = async () => { if (editNameValidationError) { setEditPageNameError(editNameValidationError); return; } try { setIsSavingPageName(true); setEditPageNameError(''); // Fetch full page data from API to preserve all fields // The backend's getFieldMapping converts missing fields to null const pageResponse = await axios.get(`/tour_pages/${editingPageId}`); const fullPageData = pageResponse.data; if (!fullPageData) { setEditPageNameError('Page not found'); return; } // Send all existing fields with only name changed await axios.put(`/tour_pages/${editingPageId}`, { id: editingPageId, data: { environment: fullPageData.environment, source_key: fullPageData.source_key, name: editPageName.trim(), slug: fullPageData.slug, sort_order: fullPageData.sort_order, background_image_url: fullPageData.background_image_url, background_video_url: fullPageData.background_video_url, background_audio_url: fullPageData.background_audio_url, background_loop: fullPageData.background_loop, background_video_autoplay: fullPageData.background_video_autoplay, background_video_loop: fullPageData.background_video_loop, background_video_muted: fullPageData.background_video_muted, background_video_start_time: fullPageData.background_video_start_time, background_video_end_time: fullPageData.background_video_end_time, design_width: fullPageData.design_width, design_height: fullPageData.design_height, requires_auth: fullPageData.requires_auth, ui_schema_json: fullPageData.ui_schema_json, }, }); // Update local state setPages((prev) => prev.map((page) => page.id === editingPageId ? { ...page, name: editPageName.trim() } : page, ), ); closeEditPageModal(); toast.success('Page name updated'); } 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 update page name.'; setEditPageNameError(message); logger.error( 'Failed to update page name:', error instanceof Error ? error : { error }, ); } finally { setIsSavingPageName(false); } }; const handleCreatePage = async () => { if (!activeProjectId) { setErrorMessage('Project not found. Please create a project first.'); return; } const name = newPageName.trim(); // Validate name if (nameValidationError) { return; } try { setIsCreatingPage(true); setErrorMessage(''); const payload = { project: activeProjectId, environment: targetEnvironment, source_key: '', name, slug: generatedSlug, sort_order: Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1, background_image_url: '', background_video_url: '', background_audio_url: '', background_loop: false, requires_auth: false, ui_schema_json: '', }; await axios.post('/tour_pages', { data: payload }); closeCreatePageModal(); await loadData(); } 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.'; setErrorMessage(message); logger.error( 'Failed to create page:', error instanceof Error ? error : { error }, ); } finally { setIsCreatingPage(false); } }; const handleCreateTransition = async () => { // Transitions are now set directly on navigation elements as transitionVideoUrl toast.info('Transitions are configured directly on navigation elements.'); }; const handleSaveTransitionSettings = async () => { if (!selectedProjectId) return; setIsSavingTransitionSettings(true); setTransitionSaveSuccess(false); try { // Check if all values are empty (should delete to use global defaults) const hasValues = localTransitionType || localDurationMs !== '' || localEasing || localOverlayColor; if (!hasValues) { // Delete the settings record to revert to global defaults await dispatch( deleteByProjectAndEnv({ projectId: selectedProjectId, environment: 'dev', }), ).unwrap(); } else { // Build the settings object with snake_case keys for the backend const settingsToSave = { transition_type: localTransitionType || 'fade', duration_ms: localDurationMs !== '' ? (localDurationMs as number) : 700, easing: localEasing || 'ease-in-out', overlay_color: localOverlayColor || '#000000', }; await dispatch( upsertByProjectAndEnv({ projectId: selectedProjectId, environment: 'dev', data: settingsToSave, }), ).unwrap(); } setTransitionSaveSuccess(true); setTimeout(() => setTransitionSaveSuccess(false), 2000); } catch (error) { logger.error('Failed to save project transition settings:', error); toast.error('Failed to save transition settings'); } finally { setIsSavingTransitionSettings(false); } }; const handleDelete = async ( event: React.MouseEvent, id: string, type: 'page' | 'transition', ) => { event.stopPropagation(); try { setDeletingId(id); setErrorMessage(''); if (type === 'page') { await axios.delete(`/tour_pages/${id}`); setPages((prev) => prev.filter((item) => item.id !== id)); } } catch (error: unknown) { const axiosError = error as { response?: { data?: { message?: string } }; }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || 'Failed to delete item.', ); logger.error( 'Failed to delete item:', error instanceof Error ? error : { error }, ); } finally { setDeletingId(''); } }; return ( <> {getPageTitle('Pages & Transitions')} {''}

{isLoading ? 'Loading project...' : projects.find((project) => project.id === activeProjectId) ?.label || 'No project selected'}

{/* Project Transition Settings */} {selectedProjectId && ( {isTransitionSettingsExpanded && (

Override global transition defaults for this project (dev environment). Changes are copied to Stage when you "Save to Stage" and to Production when you "Publish". Leave empty to use global defaults. {globalDefaults && ( (Global: {globalDefaults.transition_type},{' '} {globalDefaults.duration_ms}ms, {globalDefaults.easing},{' '} {globalDefaults.overlay_color ?? '#000000'}) )}

{ const val = e.target.value; setLocalDurationMs( val === '' ? '' : Math.max(0, parseInt(val, 10) || 0), ); }} />
setLocalOverlayColor(e.target.value)} /> setLocalOverlayColor(e.target.value)} />
{transitionSaveSuccess && ( Saved successfully! )}
)}
)}
handleNameChange(event.target.value)} placeholder='Enter page name' className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800' autoFocus maxLength={255} /> {nameValidationError && (

{nameValidationError}

)}
{/* Edit Page Name Modal */}
handleEditNameChange(event.target.value)} placeholder='Enter page name' className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800' autoFocus maxLength={255} /> {(editPageNameError || editNameValidationError) && (

{editPageNameError || editNameValidationError}

)}
{errorMessage && (

{errorMessage}

)} {isLoading ? (

Loading pages and transitions...

) : listEntries.length === 0 ? (

No pages or transitions yet.

) : (
    {listEntries.map((entry) => { const canDelete = entry.type === 'page' ? canDeletePage : canDeleteTransition; const isDeleting = deletingId === entry.id; const pageData = entry.type === 'page' ? pages.find((p) => p.id === entry.id) : null; return (
  • openConstructor( entry.parentPageId, entry.id, entry.type, ) } onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openConstructor( entry.parentPageId, entry.id, entry.type, ); } }} >

    {entry.description}

    {entry.title}

    Route: {entry.routeLabel}

    {entry.type === 'page' && pageData && ( openEditPageModal(event, pageData) } /> )} handleDelete(event, entry.id, entry.type) } disabled={!canDelete || isDeleting} />
  • ); })}
)}
); }; export default TourFlowManager;