diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/Sections/VideosSection/VideosSection.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/Sections/VideosSection/VideosSection.tsx index 16ec7fefb62..610aef7216c 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/Sections/VideosSection/VideosSection.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/Sections/VideosSection/VideosSection.tsx @@ -1,16 +1,19 @@ import Box from '@mui/material/Box' import Button from '@mui/material/Button' import CircularProgress from '@mui/material/CircularProgress' +import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useTranslation } from 'next-i18next' -import { ReactElement } from 'react' +import { KeyboardEvent, ReactElement, useState } from 'react' import { useDropzone } from 'react-dropzone' import { useJourney } from '@core/journeys/ui/JourneyProvider' import { useTemplateVideoUpload } from '../../../../TemplateVideoUploadProvider' import { + extractYouTubeVideoId, getCustomizableCardVideoBlock, getVideoBlockDisplayTitle } from '../../utils' @@ -102,7 +105,13 @@ export function VideosSection({ }: VideosSectionProps): ReactElement { const { t } = useTranslation('apps-journeys-admin') const { journey } = useJourney() - const { startUpload, getUploadStatus } = useTemplateVideoUpload() + const { startUpload, startYouTubeLink, getUploadStatus } = + useTemplateVideoUpload() + + const [youtubeUrl, setYoutubeUrl] = useState('') + const [youtubeUrlError, setYoutubeUrlError] = useState( + undefined + ) const videoBlock = getCustomizableCardVideoBlock(journey, cardBlockId) const videoBlockDisplayTitle = @@ -130,6 +139,25 @@ export function VideosSection({ disabled: loading }) + async function handleYouTubeSubmit(): Promise { + const extractedId = extractYouTubeVideoId(youtubeUrl.trim()) + if (extractedId == null) { + setYoutubeUrlError(t('Please enter a valid YouTube URL')) + return + } + if (videoBlock == null) return + + setYoutubeUrlError(undefined) + setYoutubeUrl('') + await startYouTubeLink(videoBlock.id, extractedId) + } + + function handleYouTubeKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + void handleYouTubeSubmit() + } + } + return ( @@ -167,6 +195,44 @@ export function VideosSection({ defaultMessage={t('Max size is 1 GB')} errorMessage={errorMessage} /> + + + {t('or')} + + + + + setYoutubeUrl(e.target.value)} + onKeyDown={handleYouTubeKeyDown} + disabled={loading} + error={youtubeUrlError != null} + helperText={youtubeUrlError} + inputProps={{ 'aria-label': t('YouTube URL') }} + sx={{ flex: 1 }} + /> + + + ) } diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/index.ts b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/index.ts index 3b533abf76f..d1813a2a0ed 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/index.ts +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/index.ts @@ -4,7 +4,8 @@ export { showVideosSection, getCustomizableCardVideoBlock, getVideoBlockDisplayTitle, - getVideoPoster + getVideoPoster, + extractYouTubeVideoId } from './videoSectionUtils' export { getCustomizableMediaSteps, diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/index.ts b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/index.ts index 73f187a4ee0..032bdd7f8bc 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/index.ts +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/index.ts @@ -2,5 +2,6 @@ export { getCustomizableCardVideoBlock, showVideosSection, getVideoBlockDisplayTitle, - getVideoPoster + getVideoPoster, + extractYouTubeVideoId } from './videoSectionUtils' diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/videoSectionUtils.ts b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/videoSectionUtils.ts index 211136edd03..e7cabba4acc 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/videoSectionUtils.ts +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/MediaScreen/utils/videoSectionUtils/videoSectionUtils.ts @@ -5,6 +5,18 @@ import { import { VideoBlockSource } from '../../../../../../../../__generated__/globalTypes' import { getJourneyMedia } from '../../../../../utils/getJourneyMedia' +const YOUTUBE_ID_REGEX = /(\/|%3D|vi=|v=)([0-9A-Za-z-_]{11})([%#?&/]|$)/ + +/** + * Extracts an 11-character YouTube video ID from a URL. + * + * Supports standard watch URLs, youtu.be short links, shorts, and embed URLs. + * Returns null when no valid ID can be found. + */ +export function extractYouTubeVideoId(url: string): string | null { + return url.match(YOUTUBE_ID_REGEX)?.[2] ?? null +} + /** * Returns the first customizable video block that belongs to the given card. * diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/TemplateVideoUploadProvider.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/TemplateVideoUploadProvider.tsx index 8495e808621..68e1f17f2aa 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/TemplateVideoUploadProvider.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/TemplateVideoUploadProvider.tsx @@ -16,6 +16,7 @@ import type { TemplateVideoUploadContextType } from './types' import { MAX_VIDEO_SIZE, createInitialTask } from './types' import { useMuxVideoProcessing } from './useMuxVideoProcessing' import { useUploadTaskMap } from './useUploadTaskMap' +import { useYouTubeVideoLinking } from './useYouTubeVideoLinking' const TemplateVideoUploadContext = createContext< TemplateVideoUploadContextType | undefined @@ -56,6 +57,13 @@ export function TemplateVideoUploadProvider({ activeBlocksRef }) + const { linkYouTubeVideo } = useYouTubeVideoLinking({ + setUploadTasks, + updateTask, + removeTask, + activeBlocksRef + }) + const [createMuxVideoUploadByFile] = useMutation( CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION ) @@ -167,10 +175,11 @@ export function TemplateVideoUploadProvider({ const value = useMemo( () => ({ startUpload, + startYouTubeLink: linkYouTubeVideo, getUploadStatus, hasActiveUploads }), - [startUpload, getUploadStatus, hasActiveUploads] + [startUpload, linkYouTubeVideo, getUploadStatus, hasActiveUploads] ) return ( diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/types.ts b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/types.ts index d14b21b7a59..91e7485234d 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/types.ts +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/types.ts @@ -18,6 +18,7 @@ export interface VideoUploadState { export interface TemplateVideoUploadContextType { startUpload: (videoBlockId: string, file: File) => void + startYouTubeLink: (videoBlockId: string, youtubeVideoId: string) => Promise getUploadStatus: (videoBlockId: string) => VideoUploadState | null hasActiveUploads: boolean } diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/useYouTubeVideoLinking.ts b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/useYouTubeVideoLinking.ts new file mode 100644 index 00000000000..1d932d58715 --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/TemplateVideoUploadProvider/useYouTubeVideoLinking.ts @@ -0,0 +1,116 @@ +import { useMutation } from '@apollo/client' +import { useTranslation } from 'next-i18next' +import { useSnackbar } from 'notistack' +import { Dispatch, RefObject, SetStateAction, useCallback } from 'react' + +import { useJourney } from '@core/journeys/ui/JourneyProvider' +import { GET_JOURNEY } from '@core/journeys/ui/useJourneyQuery' + +import { + IdType, + VideoBlockSource +} from '../../../../../__generated__/globalTypes' +import { VIDEO_BLOCK_UPDATE } from '../../../Editor/Slider/Settings/CanvasDetails/Properties/blocks/Video/Options/VideoOptions' + +import type { UploadTaskInternal } from './types' + +interface UseYouTubeVideoLinkingParams { + setUploadTasks: Dispatch>> + updateTask: ( + videoBlockId: string, + updates: Partial + ) => void + removeTask: (videoBlockId: string) => void + activeBlocksRef: RefObject> +} + +/** + * Manages persisting a YouTube video URL to a video block via VIDEO_BLOCK_UPDATE. + * + * Unlike Mux uploads there is no file transfer or polling — just a single + * mutation call. The block is marked as 'updating' for the duration so that + * `hasActiveUploads` stays true, preventing navigation away from the media screen. + */ +export function useYouTubeVideoLinking({ + setUploadTasks, + updateTask, + removeTask, + activeBlocksRef +}: UseYouTubeVideoLinkingParams): { + linkYouTubeVideo: (videoBlockId: string, youtubeVideoId: string) => Promise +} { + const { t } = useTranslation('apps-journeys-admin') + const { enqueueSnackbar } = useSnackbar() + const { journey } = useJourney() + + const [videoBlockUpdate] = useMutation(VIDEO_BLOCK_UPDATE) + + const linkYouTubeVideo = useCallback( + async (videoBlockId: string, youtubeVideoId: string) => { + if (activeBlocksRef.current.has(videoBlockId)) return + if (journey?.id == null) return + + activeBlocksRef.current.add(videoBlockId) + setUploadTasks((prev) => { + const next = new Map(prev) + next.set(videoBlockId, { + videoBlockId, + status: 'updating', + progress: 0, + retryCount: 0 + }) + return next + }) + + try { + await videoBlockUpdate({ + variables: { + id: videoBlockId, + input: { + videoId: youtubeVideoId, + source: VideoBlockSource.youTube + } + }, + refetchQueries: [ + { + query: GET_JOURNEY, + variables: { + id: journey.id, + idType: IdType.databaseId, + options: { skipRoutingFilter: true } + } + } + ] + }) + enqueueSnackbar(t('YouTube video set successfully'), { + variant: 'success', + autoHideDuration: 2000 + }) + removeTask(videoBlockId) + } catch { + enqueueSnackbar(t('Failed to set YouTube video. Please try again'), { + variant: 'error', + autoHideDuration: 2000 + }) + updateTask(videoBlockId, { + status: 'error', + error: 'Failed to set YouTube video. Please try again' + }) + } finally { + activeBlocksRef.current.delete(videoBlockId) + } + }, + [ + activeBlocksRef, + journey?.id, + videoBlockUpdate, + enqueueSnackbar, + t, + setUploadTasks, + updateTask, + removeTask + ] + ) + + return { linkYouTubeVideo } +}