diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts
new file mode 100644
index 00000000000..879985d8772
--- /dev/null
+++ b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts
@@ -0,0 +1,22 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: TemplateCustomizeCreateMuxVideoUploadByFileMutation
+// ====================================================
+
+export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile {
+ __typename: "MuxVideo";
+ uploadUrl: string | null;
+ id: string;
+}
+
+export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation {
+ createMuxVideoUploadByFile: TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile;
+}
+
+export interface TemplateCustomizeCreateMuxVideoUploadByFileMutationVariables {
+ name: string;
+}
diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts
new file mode 100644
index 00000000000..0f392894465
--- /dev/null
+++ b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts
@@ -0,0 +1,24 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: TemplateCustomizeGetMyMuxVideoQuery
+// ====================================================
+
+export interface TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo {
+ __typename: "MuxVideo";
+ id: string;
+ assetId: string | null;
+ playbackId: string | null;
+ readyToStream: boolean;
+}
+
+export interface TemplateCustomizeGetMyMuxVideoQuery {
+ getMyMuxVideo: TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo;
+}
+
+export interface TemplateCustomizeGetMyMuxVideoQueryVariables {
+ id: string;
+}
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts
new file mode 100644
index 00000000000..c7b781c6ef0
--- /dev/null
+++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts
@@ -0,0 +1,2 @@
+export { useVideoUpload } from './useVideoUpload'
+export type { VideoUploadStatus } from './useVideoUpload'
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx
new file mode 100644
index 00000000000..dbdf2637014
--- /dev/null
+++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx
@@ -0,0 +1,387 @@
+import { MockedProvider, MockedResponse } from '@apollo/client/testing'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { UpChunk } from '@mux/upchunk'
+import {
+ useVideoUpload,
+ CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION,
+ GET_MY_MUX_VIDEO_QUERY
+} from './useVideoUpload'
+
+jest.mock('@mux/upchunk', () => ({
+ UpChunk: {
+ createUpload: jest.fn()
+ }
+}))
+
+jest.mock('react-dropzone', () => ({
+ useDropzone: jest.fn(() => ({
+ getRootProps: jest.fn(),
+ getInputProps: jest.fn(),
+ open: jest.fn()
+ }))
+}))
+
+const createMuxVideoUploadByFileMock: MockedResponse = {
+ request: {
+ query: CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION,
+ variables: { name: 'video.mp4' }
+ },
+ result: {
+ data: {
+ createMuxVideoUploadByFile: {
+ __typename: 'MuxVideo',
+ uploadUrl: 'https://mux.com/upload',
+ id: 'videoId'
+ }
+ }
+ }
+}
+
+const getMyMuxVideoMock: MockedResponse = {
+ request: {
+ query: GET_MY_MUX_VIDEO_QUERY,
+ variables: { id: 'videoId' }
+ },
+ result: {
+ data: {
+ getMyMuxVideo: {
+ __typename: 'MuxVideo',
+ id: 'videoId',
+ assetId: 'assetId',
+ playbackId: 'playbackId',
+ readyToStream: true
+ }
+ }
+ }
+}
+
+const getMyMuxVideoProcessingMock: MockedResponse = {
+ request: {
+ query: GET_MY_MUX_VIDEO_QUERY,
+ variables: { id: 'videoId' }
+ },
+ result: {
+ data: {
+ getMyMuxVideo: {
+ __typename: 'MuxVideo',
+ id: 'videoId',
+ assetId: 'assetId',
+ playbackId: 'playbackId',
+ readyToStream: false
+ }
+ }
+ }
+}
+
+describe('useVideoUpload', () => {
+ const file = new File([''], 'video.mp4', { type: 'video/mp4' })
+
+ it('should handle successful upload and polling', async () => {
+ const onUploadComplete = jest.fn()
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result } = renderHook(() => useVideoUpload({ onUploadComplete }), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ expect(result.current.status).toBe('uploading')
+ expect(UpChunk.createUpload).toHaveBeenCalledWith({
+ endpoint: 'https://mux.com/upload',
+ file,
+ chunkSize: 5120
+ })
+
+ // Simulate progress
+ const progressCallback = mockUpload.on.mock.calls.find(
+ (call) => call[0] === 'progress'
+ )[1]
+ act(() => {
+ progressCallback({ detail: 50 })
+ })
+ expect(result.current.progress).toBe(50)
+
+ // Simulate success
+ const successCallback = mockUpload.on.mock.calls.find(
+ (call) => call[0] === 'success'
+ )[1]
+ await act(async () => {
+ successCallback()
+ })
+
+ await waitFor(() => expect(result.current.status).toBe('completed'))
+ expect(onUploadComplete).toHaveBeenCalledWith('videoId')
+ })
+
+ it('should set videoId when upload starts', async () => {
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result } = renderHook(() => useVideoUpload(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ expect(result.current.videoId).toBe('videoId')
+ })
+
+ it('should handle upload error', async () => {
+ const onUploadError = jest.fn()
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result } = renderHook(() => useVideoUpload({ onUploadError }), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ const errorCallback = mockUpload.on.mock.calls.find(
+ (call) => call[0] === 'error'
+ )[1]
+
+ act(() => {
+ errorCallback()
+ })
+
+ expect(result.current.status).toBe('error')
+ expect(result.current.error).toBe('Upload failed')
+ expect(onUploadError).toHaveBeenCalledWith('Upload failed')
+ })
+
+ it('should handle file too large error', async () => {
+ const onUploadError = jest.fn()
+ const largeFile = new File([''], 'large.mp4', { type: 'video/mp4' })
+ Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 * 1024 }) // 2GB
+
+ const { result } = renderHook(() => useVideoUpload({ onUploadError }), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(largeFile)
+ })
+
+ expect(result.current.status).toBe('error')
+ expect(result.current.error).toBe('File is too large. Max size is 1GB.')
+ expect(onUploadError).toHaveBeenCalledWith(
+ 'File is too large. Max size is 1GB.'
+ )
+ })
+
+ it('should cancel upload', async () => {
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result } = renderHook(() => useVideoUpload(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ expect(result.current.status).toBe('uploading')
+
+ act(() => {
+ result.current.cancelUpload()
+ })
+
+ expect(mockUpload.abort).toHaveBeenCalled()
+ expect(result.current.status).toBe('idle')
+ expect(result.current.progress).toBe(0)
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.videoId).toBeUndefined()
+ })
+
+ it('should poll with exponential backoff until ready', async () => {
+ const onUploadComplete = jest.fn()
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result } = renderHook(
+ () => useVideoUpload({ onUploadComplete, initialPollInterval: 100 }),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ }
+ )
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ // Simulate success to start polling
+ const successCallback = mockUpload.on.mock.calls.find(
+ (call) => call[0] === 'success'
+ )[1]
+
+ await act(async () => {
+ successCallback()
+ })
+
+ // Wait for the immediate poll to be called and for status to change to processing
+ await waitFor(() => expect(result.current.status).toBe('processing'))
+
+ // Wait for the second poll to complete and status to change to completed
+ // We use a shorter timeout since we reduced the interval to 100ms for faster testing.
+ // Note: The backoff timing here (100ms, 150ms, etc.) is reduced for test performance
+ // and does not match the real-world production intervals (2s, 3s, etc.).
+ await waitFor(() => expect(result.current.status).toBe('completed'), {
+ timeout: 2000
+ })
+ expect(onUploadComplete).toHaveBeenCalledWith('videoId')
+ })
+
+ it('should handle polling error', async () => {
+ const onUploadError = jest.fn()
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const pollingErrorMock: MockedResponse = {
+ request: {
+ query: GET_MY_MUX_VIDEO_QUERY,
+ variables: { id: 'videoId' }
+ },
+ error: new Error('Polling failed')
+ }
+
+ const { result } = renderHook(
+ () => useVideoUpload({ onUploadError, initialPollInterval: 100 }),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ }
+ )
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ // Simulate success to start polling
+ const successCallback = mockUpload.on.mock.calls.find(
+ (call) => call[0] === 'success'
+ )[1]
+
+ await act(async () => {
+ successCallback()
+ })
+
+ // Wait for all 3 retries to exhaust (3 retries * 100ms delay = ~300ms + buffer)
+ // Note: The backoff timing here is reduced for test performance and does not match
+ // the real-world production intervals.
+ await waitFor(() => expect(result.current.status).toBe('error'), {
+ timeout: 2000
+ })
+ expect(result.current.error).toBe('Failed to check video status')
+ expect(onUploadError).toHaveBeenCalledWith('Failed to check video status')
+ })
+
+ it('should cleanup on unmount', async () => {
+ const mockUpload = {
+ on: jest.fn(),
+ abort: jest.fn()
+ }
+ ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload)
+
+ const { result, unmount } = renderHook(() => useVideoUpload(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ )
+ })
+
+ await act(async () => {
+ await result.current.handleUpload(file)
+ })
+
+ unmount()
+
+ expect(mockUpload.abort).toHaveBeenCalled()
+ })
+})
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx
new file mode 100644
index 00000000000..dc0feb54dbe
--- /dev/null
+++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx
@@ -0,0 +1,236 @@
+import { gql, useLazyQuery, useMutation } from '@apollo/client'
+import { UpChunk } from '@mux/upchunk'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useDropzone } from 'react-dropzone'
+
+export const CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION = gql`
+ mutation TemplateCustomizeCreateMuxVideoUploadByFileMutation($name: String!) {
+ createMuxVideoUploadByFile(name: $name) {
+ uploadUrl
+ id
+ }
+ }
+`
+
+export const GET_MY_MUX_VIDEO_QUERY = gql`
+ query TemplateCustomizeGetMyMuxVideoQuery($id: ID!) {
+ getMyMuxVideo(id: $id) {
+ id
+ assetId
+ playbackId
+ readyToStream
+ }
+ }
+`
+
+const INITIAL_POLL_INTERVAL = 2000 // 2 seconds
+const MAX_POLL_INTERVAL = 30000 // 30 seconds
+const MAX_RETRIES = 3
+const MAX_VIDEO_SIZE = 1073741824 // 1GB
+
+export type VideoUploadStatus =
+ | 'idle'
+ | 'uploading'
+ | 'processing'
+ | 'completed'
+ | 'error'
+
+interface UseVideoUploadOptions {
+ onUploadComplete?: (videoId: string) => void
+ onUploadError?: (error: string) => void
+ /** @default 2000 */
+ initialPollInterval?: number
+ /** @default 3 */
+ maxRetries?: number
+}
+
+export function useVideoUpload({
+ onUploadComplete,
+ onUploadError,
+ initialPollInterval = INITIAL_POLL_INTERVAL,
+ maxRetries = MAX_RETRIES
+}: UseVideoUploadOptions = {}) {
+ const [status, setStatus] = useState('idle')
+ const [progress, setProgress] = useState(0)
+ const [error, setError] = useState()
+ const [videoId, setVideoId] = useState()
+
+ const uploadInstanceRef = useRef<{ abort: () => void } | null>(null)
+ const pollingTimeoutRef = useRef(null)
+ const retryCountRef = useRef(0)
+
+ const [createMuxVideoUploadByFile] = useMutation(
+ CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION
+ )
+
+ const [getMyMuxVideo] = useLazyQuery(GET_MY_MUX_VIDEO_QUERY, {
+ fetchPolicy: 'network-only'
+ })
+
+ const clearPolling = useCallback(() => {
+ if (pollingTimeoutRef.current != null) {
+ clearTimeout(pollingTimeoutRef.current)
+ pollingTimeoutRef.current = null
+ }
+ retryCountRef.current = 0
+ }, [])
+
+ const cancelUpload = useCallback(() => {
+ uploadInstanceRef.current?.abort()
+ uploadInstanceRef.current = null
+ clearPolling()
+ setStatus('idle')
+ setProgress(0)
+ setError(undefined)
+ setVideoId(undefined)
+ }, [clearPolling])
+
+ const startPolling = useCallback(
+ (videoId: string) => {
+ setStatus('processing')
+
+ const poll = async (delay: number) => {
+ try {
+ const result = await getMyMuxVideo({
+ variables: { id: videoId }
+ })
+
+ if (result.error != null) {
+ throw result.error
+ }
+
+ if (result.data?.getMyMuxVideo?.readyToStream === true) {
+ clearPolling()
+ setStatus('completed')
+ onUploadComplete?.(videoId)
+ return
+ }
+
+ // Reset retries on successful query (even if not ready)
+ retryCountRef.current = 0
+
+ // Schedule next poll with exponential backoff
+ const nextDelay = Math.min(delay * 1.5, MAX_POLL_INTERVAL)
+ pollingTimeoutRef.current = setTimeout(() => {
+ void poll(nextDelay)
+ }, delay)
+ } catch (err) {
+ // Retry on recoverable errors (e.g., network issues or 500s)
+ if (retryCountRef.current < maxRetries) {
+ retryCountRef.current++
+ pollingTimeoutRef.current = setTimeout(() => {
+ void poll(delay)
+ }, delay)
+ return
+ }
+
+ clearPolling()
+ setStatus('error')
+ setError('Failed to check video status')
+ onUploadError?.('Failed to check video status')
+ }
+ }
+
+ void poll(initialPollInterval)
+ },
+ [
+ clearPolling,
+ getMyMuxVideo,
+ onUploadComplete,
+ onUploadError,
+ initialPollInterval,
+ maxRetries
+ ]
+ )
+
+ const handleUpload = useCallback(
+ async (file: File) => {
+ if (file.size > MAX_VIDEO_SIZE) {
+ const message = 'File is too large. Max size is 1GB.'
+ setStatus('error')
+ setError(message)
+ onUploadError?.(message)
+ return
+ }
+
+ setStatus('uploading')
+ setProgress(0)
+ setError(undefined)
+
+ try {
+ const { data } = await createMuxVideoUploadByFile({
+ variables: { name: file.name }
+ })
+
+ const uploadUrl = data?.createMuxVideoUploadByFile?.uploadUrl
+ const videoId = data?.createMuxVideoUploadByFile?.id
+ setVideoId(videoId)
+
+ if (uploadUrl == null || videoId == null) {
+ throw new Error('Failed to create upload URL')
+ }
+
+ const upload = UpChunk.createUpload({
+ endpoint: uploadUrl,
+ file,
+ chunkSize: 5120 // 5MB
+ })
+
+ uploadInstanceRef.current = upload
+
+ upload.on('progress', (progress) => {
+ setProgress(progress.detail)
+ })
+
+ upload.on('success', () => {
+ uploadInstanceRef.current = null
+ startPolling(videoId)
+ })
+
+ upload.on('error', () => {
+ setStatus('error')
+ setError('Upload failed')
+ onUploadError?.('Upload failed')
+ })
+ } catch (err) {
+ setStatus('error')
+ const message = err instanceof Error ? err.message : 'Upload failed'
+ setError(message)
+ onUploadError?.(message)
+ }
+ },
+ [createMuxVideoUploadByFile, startPolling, onUploadError]
+ )
+
+ const { getRootProps, getInputProps, open } = useDropzone({
+ onDropAccepted: (files) => {
+ void handleUpload(files[0])
+ },
+ noDrag: true,
+ multiple: false,
+ accept: { 'video/*': [] },
+ disabled: status !== 'idle' && status !== 'error' && status !== 'completed'
+ })
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ uploadInstanceRef.current?.abort()
+ if (pollingTimeoutRef.current != null) {
+ clearTimeout(pollingTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ return {
+ handleUpload,
+ cancelUpload,
+ status,
+ progress,
+ error,
+ videoId,
+ open,
+ getInputProps,
+ getRootProps
+ }
+}