diff --git a/src/common/Pages.tsx b/src/common/Pages.tsx index 730061d7e..cafaa0eac 100644 --- a/src/common/Pages.tsx +++ b/src/common/Pages.tsx @@ -28,6 +28,7 @@ import Profile from 'src/pages/Profile'; import Template from 'src/pages/Template'; import Templates from 'src/pages/Templates'; import Video from 'src/pages/Video'; +import VideoShared from 'src/pages/VideoShared'; import Videos from 'src/pages/Videos'; import { Redirect } from './Redirect'; @@ -111,6 +112,10 @@ const Pages = () => { path={`/${langPrefix}/campaigns/:campaignId/videos/:videoId`} element={} /> + } + /> } diff --git a/src/pages/VideoShared/Actions.tsx b/src/pages/VideoShared/Actions.tsx new file mode 100644 index 000000000..dd0974bd3 --- /dev/null +++ b/src/pages/VideoShared/Actions.tsx @@ -0,0 +1,144 @@ +import { LG, Skeleton, Tag, XL } from '@appquality/unguess-design-system'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as ClockIcon } from 'src/assets/icons/time-icon.svg'; +import { capitalizeFirstLetter } from 'src/common/capitalizeFirstLetter'; +import { getDeviceIcon } from 'src/common/components/BugDetail/Meta'; +import { Divider } from 'src/common/components/divider'; +import { Meta } from 'src/common/components/Meta'; +import { Pipe } from 'src/common/components/Pipe'; +import { + useGetVideosByVidObservationsQuery, + useGetVideosByVidQuery, +} from 'src/features/api'; +import styled from 'styled-components'; +import { formatDuration } from '../Videos/utils/formatDuration'; +import { getSeverityTagsByVideoCount } from '../Videos/utils/getSeverityTagsWithCount'; +import { NoObservations } from './components/NoObservations'; +import { Observation } from './components/Observation'; +import { SentimentOverview } from './components/SentimentOverview'; + +const Container = styled.div` + display: flex; + flex-direction: column; + position: sticky; + top: 0; + width: 100%; + background-color: white; + height: 100vh; + padding: ${({ theme }) => theme.space.md} ${({ theme }) => theme.space.md}; + overflow-y: auto; + border-left: 1px solid ${({ theme }) => theme.palette.grey[200]}; + scroll-behavior: smooth; +`; +const MetaContainer = styled.div` + display: flex; + align-items: center; + margin-top: ${({ theme }) => theme.space.sm}; + margin-bottom: ${({ theme }) => theme.space.xs}; +`; + +const ObservationsCountWrapper = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + row-gap: ${({ theme }) => theme.space.xxs}; + margin-top: ${({ theme }) => theme.space.sm}; +`; + +const Actions = () => { + const { videoId } = useParams(); + const refScroll = useRef(null); + const { t } = useTranslation(); + const { + data: video, + isFetching: isFetchingVideo, + isLoading: isLoadingVideo, + isError: isErrorVideo, + } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + const { + data: observations, + isFetching: isFetchingObservations, + isLoading: isLoadingObservations, + isError: isErrorObservations, + } = useGetVideosByVidObservationsQuery({ + vid: videoId || '', + }); + + const severities = observations + ? getSeverityTagsByVideoCount(observations) + : []; + + if (!video || isErrorVideo) return null; + if (!observations || isErrorObservations) return null; + + if (isFetchingVideo || isLoadingVideo) return ; + if (isFetchingObservations || isLoadingObservations) return ; + + return ( + + + {video.tester.name} + + + Tester ID: {video.tester.id} + + {video.tester.device && ( + + {getDeviceIcon(video.tester.device.type)} + {video.tester.device.type} + + )} + {video.duration && ( + + + + + {formatDuration(video.duration)} + + )} + + + + + + {t('__OBSERVATIONS_DRAWER_TOTAL')}: {observations.length} + + {observations && severities && severities.length > 0 && ( + + {severities.map((severity) => ( + + {capitalizeFirstLetter(severity.name)} + + ))} + + )} + + {observations && observations.length ? ( + observations.map((observation) => ( + + )) + ) : ( + + )} + + ); +}; + +export default Actions; diff --git a/src/pages/VideoShared/Content.tsx b/src/pages/VideoShared/Content.tsx new file mode 100644 index 000000000..1b2593476 --- /dev/null +++ b/src/pages/VideoShared/Content.tsx @@ -0,0 +1,24 @@ +import { Col, Grid, Row } from '@appquality/unguess-design-system'; +import { LayoutWrapper } from 'src/common/components/LayoutWrapper'; +import Actions from './Actions'; +import { VideoPlayer } from './components/Player'; +import { VideoContextProvider } from './context/VideoContext'; + +const VideoPageContent = () => ( + + + + + + + + + + + + + + +); + +export default VideoPageContent; diff --git a/src/pages/VideoShared/components/ConfirmDeleteModal.tsx b/src/pages/VideoShared/components/ConfirmDeleteModal.tsx new file mode 100644 index 000000000..eccf9c501 --- /dev/null +++ b/src/pages/VideoShared/components/ConfirmDeleteModal.tsx @@ -0,0 +1,77 @@ +import { + Button, + Modal, + ModalClose, + useToast, + Notification, +} from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { useDeleteVideosByVidObservationsAndOidMutation } from 'src/features/api'; + +export const ConfirmDeleteModal = ({ + observationId, + setIsConfirmationModalOpen, +}: { + observationId: number; + setIsConfirmationModalOpen: (isOpen: boolean) => void; +}) => { + const { videoId } = useParams(); + const { t } = useTranslation(); + const { addToast } = useToast(); + const [deleteObservation] = useDeleteVideosByVidObservationsAndOidMutation(); + + const onQuit = () => { + setIsConfirmationModalOpen(false); + }; + + const onContinue = async () => { + deleteObservation({ vid: videoId || '', oid: observationId.toString() }) + .unwrap() + .then(() => { + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + setIsConfirmationModalOpen(false); + }) + .catch(() => {}); + }; + + return ( + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_DELETE_MODAL_HEADER_TITLE')} + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_DELETE_MODAL_BODY_TEXT')} + + + + {t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_DELETE_MODAL_CONTINUE_BUTTON' + )} + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_DELETE_MODAL_QUIT_BUTTON')} + + + + + ); +}; diff --git a/src/pages/VideoShared/components/NoObservations.tsx b/src/pages/VideoShared/components/NoObservations.tsx new file mode 100644 index 000000000..1141f6d64 --- /dev/null +++ b/src/pages/VideoShared/components/NoObservations.tsx @@ -0,0 +1,20 @@ +import { Paragraph, XXL } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as EmptyImage } from 'src/assets/empty-observations.svg'; + +const NoObservations = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t('__VIDEO_PAGE_NO_OBSERVATIONS_TITLE')} + + {t('__VIDEO_PAGE_NO_OBSERVATIONS')} + > + ); +}; + +export { NoObservations }; diff --git a/src/pages/VideoShared/components/Observation.tsx b/src/pages/VideoShared/components/Observation.tsx new file mode 100644 index 000000000..c46b4e958 --- /dev/null +++ b/src/pages/VideoShared/components/Observation.tsx @@ -0,0 +1,219 @@ +import { + AccordionNew, + IconButton, + Notification, + Tooltip, + useToast, +} from '@appquality/unguess-design-system'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as LinkIcon } from 'src/assets/icons/link-stroke.svg'; +import { ReactComponent as TagIcon } from 'src/assets/icons/tag-icon.svg'; +import { Divider } from 'src/common/components/divider'; +import { getColorWithAlpha } from 'src/common/utils'; +import { + GetVideosByVidApiResponse, + GetVideosByVidObservationsApiResponse, +} from 'src/features/api'; +import { useLocalizeRoute } from 'src/hooks/useLocalizedRoute'; +import { formatDuration } from 'src/pages/Videos/utils/formatDuration'; +import { styled } from 'styled-components'; +import { useVideoContext } from '../context/VideoContext'; +import { ObservationForm } from './ObservationForm'; + +const Circle = styled.div<{ + color: string; +}>` + width: 20px; + height: 20px; + margin-right: ${({ theme }) => theme.space.sm}; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; +`; + +const Observation = ({ + observation, + refScroll, + transcript, +}: { + observation: GetVideosByVidObservationsApiResponse[number]; + refScroll: React.RefObject; + transcript?: GetVideosByVidApiResponse['transcript']; +}) => { + const { tags, start, end } = observation; + const [isOpen, setIsOpen] = useState(false); + const { openAccordion, setOpenAccordion } = useVideoContext(); + const { campaignId, videoId } = useParams(); + const pageUrl = useLocalizeRoute( + `campaigns/${campaignId}/videos/${videoId}/` + ); + const { addToast } = useToast(); + const { t } = useTranslation(); + + const title = tags.find((tag) => tag.group.name.toLowerCase() === 'title') + ?.tag.name; + + const handleAccordionChange = () => { + setIsOpen(!isOpen); + if (!isOpen) { + setOpenAccordion(undefined); + } + }; + + const copyLink = useCallback( + (anchor: string, event: React.MouseEvent) => { + event.stopPropagation(); + navigator.clipboard.writeText( + `${window.location.origin}${pageUrl}#${anchor}` + ); + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + }, + [observation] + ); + + const handleSubmit = () => { + setIsOpen(false); + }; + + useEffect(() => { + if (openAccordion !== undefined) { + if (openAccordion.id === observation.id) { + setIsOpen(true); + + // Set scrolling container position to active element + setTimeout(() => { + if (!refScroll.current) { + return; + } + + const activeElement = document.getElementById( + `video-observation-accordion-${openAccordion.id}` + ); + if (activeElement) { + refScroll.current.scrollTo({ + top: activeElement.offsetTop - 150, // account for header height + behavior: 'smooth', + }); + } + setOpenAccordion(undefined); + }, 100); + } + } + }, [openAccordion]); + + // Check if url has an anchor and scroll to it + useEffect(() => { + const url = window.location.href; + const urlAnchor = url.split('#')[1]; + if (urlAnchor) { + const observationId = parseInt(urlAnchor.replace('observation-', ''), 10); + if (observationId) { + setOpenAccordion({ id: observationId }); + } + const anchor = document.getElementById(urlAnchor); + const main = document.getElementById('main'); + if (anchor && main) { + main.scroll({ + top: anchor.offsetTop, + left: 0, + behavior: 'smooth', + }); + } + } + }, []); + + return ( + <> + + + + tag.group.name.toLowerCase() === 'severity' + )?.tag.style || appTheme.palette.grey[600] + } + style={{ + backgroundColor: getColorWithAlpha( + observation.tags.find( + (tag) => tag.group.name.toLowerCase() === 'severity' + )?.tag.style || appTheme.palette.grey[600], + 0.1 + ), + }} + > + tag.group.name.toLowerCase() === 'severity' + )?.tag.style || appTheme.palette.grey[600], + }} + /> + + } + > + + + + + copyLink(`observation-${observation.id}`, event) + } + > + + + + + + + + + + + > + ); +}; + +export { Observation }; diff --git a/src/pages/VideoShared/components/ObservationForm.tsx b/src/pages/VideoShared/components/ObservationForm.tsx new file mode 100644 index 000000000..473b9d9ea --- /dev/null +++ b/src/pages/VideoShared/components/ObservationForm.tsx @@ -0,0 +1,357 @@ +import { + FormField, + Label, + Message, + MultiSelect, + Radio, + SM, + Skeleton, + Tag, + Textarea, +} from '@appquality/unguess-design-system'; +import { Form, Formik, FormikHelpers, FormikProps } from 'formik'; +import { ComponentProps, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { getColorWithAlpha } from 'src/common/utils'; +import { + GetCampaignsByCidVideoTagsApiResponse, + GetVideosByVidObservationsApiResponse, + Paragraph, + useGetCampaignsByCidVideoTagsQuery, +} from 'src/features/api'; +import { styled } from 'styled-components'; +import * as Yup from 'yup'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; +import { TooltipModalContextProvider } from './context'; +import { ObservationFormValues, TitleDropdown } from './TitleDropdownNew'; + +const FormContainer = styled.div` + padding: ${({ theme }) => theme.space.md} ${({ theme }) => theme.space.xxs}; +`; + +const StyledLabel = styled(Label)` + display: block; + margin-bottom: ${({ theme }) => theme.space.xs}; +`; + +export const RadioTag = styled(Tag)<{ + color: string; +}>` + padding: ${({ theme }) => theme.space.sm} ${({ theme }) => theme.space.xxs}; + + * { + user-select: none; + } +`; +const ObservationForm = ({ + observation, + paragraphs, +}: { + observation: GetVideosByVidObservationsApiResponse[number]; + paragraphs?: Paragraph[]; + onSubmit: ( + values: ObservationFormValues, + actions: FormikHelpers + ) => void; +}) => { + const { t } = useTranslation(); + const { campaignId } = useParams(); + const formRef = useRef>(null); + const [options, setOptions] = useState< + ComponentProps['options'] + >([]); + const [selectedSeverity] = useState< + GetCampaignsByCidVideoTagsApiResponse[number]['tags'][number] | undefined + >( + observation.tags?.find((tag) => tag.group.name.toLowerCase() === 'severity') + ?.tag + ); + const [selectedOptions] = useState<{ id: number; label: string }[]>( + observation.tags + ?.filter( + (tag) => + tag.group.name.toLowerCase() !== 'severity' && + tag.group.name.toLowerCase() !== 'title' + ) + .map((tag) => ({ + id: tag.tag.id, + label: tag.tag.name, + })) || [] + ); + const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); + const { + data: tags, + isLoading, + isFetching, + } = useGetCampaignsByCidVideoTagsQuery({ + cid: campaignId || '', + }); + + const validationSchema = Yup.object().shape({ + title: Yup.number() + .min(1, t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_ERROR')) + .required(t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_ERROR')), + severity: Yup.number() + .min(1, t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_SEVERITY_ERROR')) + .required( + t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_SEVERITY_ERROR') + ), + quotes: Yup.string().required( + t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_QUOTS_ERROR') + ), + }); + + const severities = tags?.filter( + (tag) => tag.group.name.toLowerCase() === 'severity' + )?.[0]; + + const titles = tags?.filter( + (tag) => tag.group.name.toLowerCase() === 'title' + )?.[0]; + + useEffect(() => { + if (tags) { + setOptions( + tags + .filter( + (group) => + group.group.name.toLowerCase() !== 'severity' && + group.group.name.toLowerCase() !== 'title' + ) + .flatMap((group) => group.tags) + .sort((a, b) => b.usageNumber - a.usageNumber) + .map((tag) => ({ + id: tag.id, + itemID: tag.id.toString(), + label: `${tag.name} (${tag.usageNumber})`, + selected: selectedOptions.some((bt) => bt.id === tag.id), + })) + ); + } + }, [tags, selectedOptions]); + + function generateQuotes() { + if (!paragraphs) return undefined; + const wordsWithinRange = paragraphs + .flatMap((paragraph) => + paragraph.words.filter( + (word) => + word.start >= observation.start && word.end <= observation.end + ) + ) + .map((word) => word.word) + .join(' '); + + return wordsWithinRange; + } + + const formInitialValues = { + title: + observation?.tags?.find((tag) => tag.group.name.toLowerCase() === 'title') + ?.tag.id || 0, + severity: + observation?.tags?.find( + (tag) => tag.group.name.toLowerCase() === 'severity' + )?.tag.id || 0, + notes: observation?.description || '', + quotes: observation?.quotes || generateQuotes() || '', + }; + + return ( + + + {}} + > + {(formProps: FormikProps) => ( + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_LABEL')} + + * + + + {t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DESCRIPTION' + )} + + + + + + {formProps.errors.title && ( + + {formProps.errors.title} + + )} + {severities && severities.tags.length > 0 && ( + + + {t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_SEVERITY_LABEL' + )} + + * + + + {isLoading ? ( + + ) : ( + <> + + {/* Radio buttons for severity selection */} + {severities.tags.map((severity) => ( + + + + + {severity.name} + + + + + ))} + + {formProps.errors.severity && ( + + {formProps.errors.severity} + + )} + > + )} + + )} + {/* ExtraTags Field */} + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_LABEL')} + + {isLoading ? ( + + ) : ( + o.selected)} + // listboxAppendToNode={document.body} + // creatable + maxItems={4} + size="medium" + i18n={{ + placeholder: t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_PLACEHOLDER' + ), + }} + /> + )} + + {/* Quotes Field */} + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_QUOTS_LABEL')} + + * + + + + {formProps.errors.quotes && ( + + {formProps.errors.quotes} + + )} + + {/* Notes Field */} + + + {t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_NOTES_LABEL')} + + + + + )} + + + {isConfirmationModalOpen && ( + + )} + + ); +}; + +export { ObservationForm }; diff --git a/src/pages/VideoShared/components/ObservationTooltip.tsx b/src/pages/VideoShared/components/ObservationTooltip.tsx new file mode 100644 index 000000000..34f283443 --- /dev/null +++ b/src/pages/VideoShared/components/ObservationTooltip.tsx @@ -0,0 +1,78 @@ +import { + Tag, + useVideoContext as usePlayerContext, +} from '@appquality/unguess-design-system'; +import { ReactComponent as TagIcon } from 'src/assets/icons/tag-icon.svg'; +import { getColorWithAlpha } from 'src/common/utils'; +import styled from 'styled-components'; +import { useVideoContext } from '../context/VideoContext'; + +const StyledTag = styled(Tag)<{ + isSelecting?: boolean; +}>` + ${({ isSelecting }) => + isSelecting && + ` + display: none; + `} + box-shadow: ${({ theme }) => theme.shadows.boxShadow(theme)}; + background: white; + position: relative; + user-select: none; + + &:hover { + cursor: pointer; + color: ${({ color }) => color}; + } + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: ${({ color }) => getColorWithAlpha(color ?? '', 0.2)}; + } + + > svg { + width: 16px; + min-width: 0; + margin-right: ${({ theme }) => theme.space.xxs}; + } +`; + +export const ObservationTooltip = ({ + observationId, + color, + label, + isSelecting, + start, +}: { + start?: number; + observationId: number; + color?: string; + label?: string; + isSelecting?: boolean; +}) => { + const { context, setIsPlaying } = usePlayerContext(); + const { setOpenAccordion } = useVideoContext(); + return ( + { + setOpenAccordion({ id: observationId }); + if (start && context?.player?.ref.current) { + context.player.ref.current.currentTime = start; + context.player.ref.current.play(); + setIsPlaying(true); + } + }} + isSelecting={isSelecting} + > + + {label} + + ); +}; diff --git a/src/pages/VideoShared/components/PageHeader/ShortcutHelper.tsx b/src/pages/VideoShared/components/PageHeader/ShortcutHelper.tsx new file mode 100644 index 000000000..27f225a92 --- /dev/null +++ b/src/pages/VideoShared/components/PageHeader/ShortcutHelper.tsx @@ -0,0 +1,106 @@ +import { + IconButton, + MD, + Modal, + ModalClose, + PlayerProvider, +} from '@appquality/unguess-design-system'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ForwardIcon } from 'src/assets/icons/forward.svg'; +import { ReactComponent as KeyboardIcon } from 'src/assets/icons/keyboard.svg'; +import { ReactComponent as MuteIcon } from 'src/assets/icons/mute.svg'; +import { ReactComponent as PlayIcon } from 'src/assets/icons/play.svg'; +import { ReactComponent as RewindIcon } from 'src/assets/icons/rewind.svg'; +import { ReactComponent as TagIcon } from 'src/assets/icons/tag-icon.svg'; +import styled from 'styled-components'; + +const TitleWrapper = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.space.xs}; +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.sm}; +`; + +const ShortcutItemWrapper = styled.div` + padding-bottom: ${({ theme }) => theme.space.sm}; + margin-top: ${({ theme }) => theme.space.sm}; + border-bottom: 1px solid ${({ theme }) => theme.palette.grey[300]}; +`; + +const ShortcutHelper = () => { + const [isOpen, setIsOpen] = useState(false); + const { t } = useTranslation(); + return ( + <> + {isOpen && ( + setIsOpen(false)}> + + + {t('UX_SHORTCUT_MODAL_TITLE')} + + setIsOpen(false)} /> + + + + {t('UX_SHORTCUT_MODAL_CONTENT_TITLE')} + + + } + type="play/pause" + > + {t('UX_SHORTCUT_PLAY_PAUSE')} + + + + + } type="mute"> + {t('UX_SHORTCUT_MUTE')} + + + + + } + type="forward" + > + {t('UX_SHORTCUT_FORWARD')} + + + + + } + type="backward" + > + {t('UX_SHORTCUT_REWIND')} + + + + + } + type="observation" + > + {t('UX_SHORTCUT_ADD_OBSERVATION')} + + + + + + + )} + setIsOpen(true)}> + + + > + ); +}; + +export { ShortcutHelper }; diff --git a/src/pages/VideoShared/components/PageHeader/UsecaseSelect.tsx b/src/pages/VideoShared/components/PageHeader/UsecaseSelect.tsx new file mode 100644 index 000000000..c0b262064 --- /dev/null +++ b/src/pages/VideoShared/components/PageHeader/UsecaseSelect.tsx @@ -0,0 +1,103 @@ +import { + Ellipsis, + MD, + Select, + Skeleton, + SM, +} from '@appquality/unguess-design-system'; +import { useCallback, useState } from 'react'; +import { Trans } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { useSendGTMevent } from 'src/hooks/useGTMevent'; +import useUsecaseWithVideos from './useUsecaseWithVideos'; + +const UsecaseSelect = ({ + currentUsecaseId, + campaignId, +}: { + currentUsecaseId: number; + campaignId: string | undefined; +}) => { + const sendGTMEvent = useSendGTMevent(); + const navigate = useNavigate(); + const [selectedItem, setSelectedItem] = useState( + currentUsecaseId.toString() + ); + + const { + usecasesWithVideos: useUsecasesWithVideos, + isLoading, + isFetching, + } = useUsecaseWithVideos(campaignId || ''); + + /** + * Navigate to the video page with the selected usecase + */ + const handleNavigate = useCallback( + (useCaseId: string) => { + const usecase = useUsecasesWithVideos?.find( + (u) => u.id === parseInt(useCaseId, 10) + ); + if (usecase) { + const videoId = usecase?.videos[0]?.id; + if (!videoId) return; + + navigate(`/campaigns/${campaignId}/videos/${videoId}/`); + } + }, + [useUsecasesWithVideos] + ); + + return !isLoading && !isFetching && useUsecasesWithVideos ? ( + { + const usecaseId = useUsecasesWithVideos + .find((usecase) => usecase.id === Number(value)) + ?.id.toString(); + + if (!usecaseId) return; + + // Tracking change usecase event + + sendGTMEvent({ + action: 'video_change_use_case', + event: 'video_navigation', + category: 'bugs', + content: usecaseId, + }); + + setSelectedItem(usecaseId); + handleNavigate(value); + }} + inputValue={selectedItem} + selectionValue={selectedItem} + renderValue={({ inputValue }) => { + const usecase = useUsecasesWithVideos?.find( + (u) => u.id === Number(inputValue) + ); + return ( + {usecase?.title?.full} + ); + }} + > + {useUsecasesWithVideos?.map((usecase) => ( + + + {usecase.title.full} + + + + video {{ count: usecase.videos.length }} + + + + ))} + + ) : ( + + ); +}; + +export default UsecaseSelect; diff --git a/src/pages/VideoShared/components/PageHeader/VideoPagination.tsx b/src/pages/VideoShared/components/PageHeader/VideoPagination.tsx new file mode 100644 index 000000000..155d89a8f --- /dev/null +++ b/src/pages/VideoShared/components/PageHeader/VideoPagination.tsx @@ -0,0 +1,81 @@ +import { Pagination, Skeleton } from '@appquality/unguess-design-system'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { GetVideosByVidApiResponse } from 'src/features/api'; +import { useSendGTMevent } from 'src/hooks/useGTMevent'; +import useUsecaseWithCounter from './useUsecaseWithVideos'; + +const VideoPagination = ({ + currentUsecaseId, + campaignId, + video, +}: { + currentUsecaseId: number; + campaignId: string | undefined; + video: GetVideosByVidApiResponse; +}) => { + const navigate = useNavigate(); + const sendGTMEvent = useSendGTMevent(); + + const { + usecasesWithVideos: useCasesWithVideoCount, + isLoading, + isFetching, + } = useUsecaseWithCounter(campaignId || ''); + + const paginationData = useMemo(() => { + const videosCurrentUsecase = useCasesWithVideoCount?.find( + (usecase) => usecase.id === currentUsecaseId + )?.videos; + if (videosCurrentUsecase && videosCurrentUsecase.length > 0) { + const index = videosCurrentUsecase.findIndex( + (item) => item.id === video.id + ); + return { + items: videosCurrentUsecase, + total: videosCurrentUsecase.length, + currentPage: index + 1, + }; + } + return { items: [], total: 0, currentPage: 1 }; + }, [useCasesWithVideoCount, video]); + + return paginationData.items.length > 0 && !isLoading && !isFetching ? ( + { + // eslint-disable-next-line no-console + if (!page) return; + const targetId = paginationData.items[page - 1].id; + + // Tracking video navigation + if (page > paginationData.currentPage) { + sendGTMEvent({ + action: 'video_next', + event: 'video_navigation', + category: 'bugs', + content: targetId.toString(), + }); + } else if (page < paginationData.currentPage) { + sendGTMEvent({ + action: 'video_previous', + event: 'video_navigation', + category: 'bugs', + content: targetId.toString(), + }); + } + + navigate(`/campaigns/${campaignId}/videos/${targetId}/`, { + replace: true, + }); + }} + /> + ) : ( + + ); +}; + +export default VideoPagination; diff --git a/src/pages/VideoShared/components/PageHeader/index.tsx b/src/pages/VideoShared/components/PageHeader/index.tsx new file mode 100644 index 000000000..37c0a316c --- /dev/null +++ b/src/pages/VideoShared/components/PageHeader/index.tsx @@ -0,0 +1,127 @@ +import { + Anchor, + PageHeader, + Skeleton, + XL, +} from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { capitalizeFirstLetter } from 'src/common/capitalizeFirstLetter'; +import { LayoutWrapper } from 'src/common/components/LayoutWrapper'; +import { + useGetCampaignsByCidQuery, + useGetVideosByVidQuery, +} from 'src/features/api'; +import { useLocalizeRoute } from 'src/hooks/useLocalizedRoute'; +import { ShortcutHelper } from './ShortcutHelper'; +import UsecaseSelect from './UsecaseSelect'; +import VideoPagination from './VideoPagination'; + +const VideoPageHeader = () => { + const { campaignId, videoId } = useParams(); + const { t } = useTranslation(); + const videosRoute = useLocalizeRoute(`campaigns/${campaignId}/videos`); + const campaignRoute = useLocalizeRoute(`campaigns/${campaignId}`); + const { + data: video, + isFetching: isFetchingVideo, + isLoading: isLoadingVideo, + isError: isErrorVideo, + } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const usecaseId = + video?.usecase.id || Number(queryParams.get('usecase')) || 0; + + const { + data: campaign, + isFetching: isFetchingCampaign, + isLoading: isLoadingCampaign, + isError: isErrorCampaign, + } = useGetCampaignsByCidQuery({ + cid: campaignId || '', + }); + + if (!video || isErrorVideo) return null; + if (!campaign || isErrorCampaign) return null; + if (isFetchingVideo || isLoadingVideo) return ; + if (isFetchingCampaign || isLoadingCampaign) return ; + + return ( + + + + + + {campaign.customer_title} + + + {t('__VIDEOS_PAGE_TITLE')} + + + + + + {capitalizeFirstLetter(video.tester.name)} + + + {/* {usecaseId && ( + + )} + + {video && ( + + )} */} + + + + + + + + + + + ); +}; + +export default VideoPageHeader; diff --git a/src/pages/VideoShared/components/PageHeader/useUsecaseWithVideos.tsx b/src/pages/VideoShared/components/PageHeader/useUsecaseWithVideos.tsx new file mode 100644 index 000000000..cf70b2793 --- /dev/null +++ b/src/pages/VideoShared/components/PageHeader/useUsecaseWithVideos.tsx @@ -0,0 +1,63 @@ +import { + useGetCampaignsByCidUsecasesQuery, + useGetCampaignsByCidVideosQuery, +} from 'src/features/api'; + +const useUsecaseWithVideos = (campaignId: string) => { + const { + data: videosCampaigns, + isLoading: isLoadingVideos, + isFetching: isFetchingVideos, + } = useGetCampaignsByCidVideosQuery({ + cid: campaignId || '', + }); + + const { + data: usecases, + isFetching: isFetchingUsecases, + isLoading: isLoadingUsecases, + } = useGetCampaignsByCidUsecasesQuery({ + cid: campaignId || '', + filterBy: 'videos', + }); + + const usecasesWithVideos = usecases + ?.map((usecase) => { + const videos = videosCampaigns?.items.filter( + (item) => item.usecaseId === usecase.id + ); + return { + ...usecase, + videos: videos || [], + }; + }) + .map((usecase) => { + const sortedVideos = usecase.videos.sort((a, b) => { + const aDeviceType = a.tester.device.type; + const bDeviceType = b.tester.device.type; + const order = ['desktop', 'tablet', 'smartphone', 'other']; + const indexA = order.indexOf(aDeviceType); + const indexB = order.indexOf(bDeviceType); + + if (indexA < indexB) { + return -1; + } + if (indexA > indexB) { + return 1; + } + return 0; + }); + return { + ...usecase, + videos: sortedVideos, + }; + }); + + return { + usecasesWithVideos, + isLoading: isLoadingVideos || isLoadingUsecases, + isFetching: isFetchingVideos || isFetchingUsecases, + }; +}; + +export default useUsecaseWithVideos; diff --git a/src/pages/VideoShared/components/Player.tsx b/src/pages/VideoShared/components/Player.tsx new file mode 100644 index 000000000..4dda3c5af --- /dev/null +++ b/src/pages/VideoShared/components/Player.tsx @@ -0,0 +1,199 @@ +import { + PlayerProvider, + Skeleton, + useToast, + useVideoContext as usePlayerContext, +} from '@appquality/unguess-design-system'; +import { ComponentProps, useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { appTheme } from 'src/app/theme'; +import { + useGetVideosByVidObservationsQuery, + useGetVideosByVidQuery, + usePatchVideosByVidObservationsAndOidMutation, + usePostVideosByVidObservationsMutation, +} from 'src/features/api'; +import { useLocalizeRoute } from 'src/hooks/useLocalizedRoute'; +import { styled } from 'styled-components'; +import { useAnalytics } from 'use-analytics'; +import { useVideoContext } from '../context/VideoContext'; +import { ObservationTooltip } from './ObservationTooltip'; +import { ToolsContextProvider } from './tools/context/ToolsContext'; +import { Transcript } from './Transcript'; +import { useSetStartTimeFromObservation } from '../useSetStartTimeFromObservation'; + +const PlayerContainer = styled.div<{ + isFetching?: boolean; +}>` + width: 100%; + height: 55vh; + display: flex; + position: relative; + padding-bottom: ${({ theme }) => theme.space.xxl}; + top: 0; + z-index: 3; + overflow: hidden; + + ${({ isFetching }) => + isFetching && + ` + opacity: 0.75; + `} + + > div { + height: auto; + } +`; + +const CorePlayer = () => { + const { videoId } = useParams(); + const { t } = useTranslation(); + const videoRef = useRef(null); + const { setOpenAccordion } = useVideoContext(); + const [patchObservation] = usePatchVideosByVidObservationsAndOidMutation(); + const [start, setStart] = useState(undefined); + const { context, setIsPlaying } = usePlayerContext(); + const { currentTime } = context.player || { currentTime: 0 }; + const { track } = useAnalytics(); + + const { + data: observations, + isLoading: isLoadingObservations, + isError: isErrorObservations, + } = useGetVideosByVidObservationsQuery({ + vid: videoId || '', + }); + const { data: video, isFetching: isFetchingVideo } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + useSetStartTimeFromObservation(observations, videoRef); + + const seekPlayer = (time: number, forcePlay?: boolean) => { + if (!videoRef?.current) return; + + videoRef.current.currentTime = time; + if (forcePlay) { + videoRef.current.play(); + setIsPlaying(true); + } + }; + + const mappedObservations = useMemo( + () => + observations?.map((obs) => ({ + id: obs.id, + start: obs.start, + end: obs.end, + title: obs.title, + hue: + obs.tags.find((tag) => tag.group.name.toLowerCase() === 'severity') + ?.tag.style || 'grey', + label: obs.title, + tooltipContent: ( + tag.group.name.toLowerCase() === 'severity' + )?.tag.style || appTheme.palette.grey[600] + } + label={obs.title} + /> + ), + onClick: () => { + setOpenAccordion({ id: obs.id }); + seekPlayer(obs.start); + }, + tags: obs.tags, + })), + [observations] + ); + + const handleBookmarksUpdateFunction: ComponentProps< + typeof PlayerProvider.Core + >['handleBookmarkUpdate'] = async (bookmark) => { + await patchObservation({ + vid: videoId || '', + oid: bookmark.id.toString(), + body: { + title: bookmark.label, + start: bookmark.start, + end: bookmark.end, + tags: bookmark.tags?.map((item: any) => item.tag.id), + }, + }).unwrap(); + }; + + if (!observations || isErrorObservations || !video) return null; + + if (isLoadingObservations) return ; + return ( + + + { + track(`player:${key}`); + }} + url={video.streamUrl ?? video.url} + // onCutHandler={handleCut} + isCutting={!!start} + bookmarks={mappedObservations} + i18n={{ + // beforeHighlight: t('__VIDEO_PAGE_PLAYER_START_ADD_OBSERVATION'), + // onHighlight: t('__VIDEO_PAGE_PLAYER_STOP_ADD_OBSERVATION'), + mute: t('UX_SHORTCUT_MUTE'), + playpause: t('UX_SHORTCUT_PLAY_PAUSE'), + forward: t('UX_SHORTCUT_FORWARD'), + backward: t('UX_SHORTCUT_REWIND'), + // observations: t('UX_SHORTCUT_ADD_OBSERVATION'), + }} + /> + + { + seekPlayer(time, false); + }} + videoId={videoId} + /> + + ); +}; + +const VideoPlayer = () => { + const { videoId } = useParams(); + const navigate = useNavigate(); + const notFoundRoute = useLocalizeRoute('oops'); + const location = useLocation(); + + const { + data: video, + isFetching: isFetchingVideo, + isLoading: isLoadingVideo, + isError: isErrorVideo, + } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + if (isErrorVideo) { + navigate(notFoundRoute, { + state: { from: location.pathname }, + }); + } + + if (!video) return null; + if (isFetchingVideo || isLoadingVideo) return ; + + return ( + + + + ); +}; + +export { VideoPlayer }; diff --git a/src/pages/VideoShared/components/SentimentOverview/index.tsx b/src/pages/VideoShared/components/SentimentOverview/index.tsx new file mode 100644 index 000000000..6188330b8 --- /dev/null +++ b/src/pages/VideoShared/components/SentimentOverview/index.tsx @@ -0,0 +1,91 @@ +import { + AccordionNew, + Button, + GlobalAlert, + SM, + Tag, +} from '@appquality/unguess-design-system'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { ReactComponent as AiIcon } from 'src/assets/icons/ai-icon.svg'; +import { ReactComponent as CopyIcon } from 'src/assets/icons/copy-icon.svg'; +import { useCopy } from 'src/hooks/useCopy'; +import styled, { useTheme } from 'styled-components'; +import { useContent } from '../Transcript/useContent'; + +const SentimentOverviewWrapper = styled.div` + margin-top: ${({ theme }) => theme.space.md}; +`; + +const CopyButton = styled(Button)` + margin-top: ${({ theme }) => theme.space.lg}; +`; + +const StyledHeader = styled(AccordionNew.Header)` + .accordion-header-inner-wrapper { + grid-template-areas: + 'supertitle supertitle' + 'label label' + 'meta meta'; + } +`; + +const StyledSM = styled(SM)` + font-style: italic; +`; + +export const SentimentOverview = () => { + const { videoId } = useParams(); + const theme = useTheme(); + const [isOpen, setIsOpen] = useState(true); + const { t } = useTranslation(); + const { generalSentiment } = useContent(videoId || ''); + const copy = useCopy({ text: generalSentiment || '' }); + + if (!generalSentiment) return null; + const handleAccordionChange = () => { + setIsOpen((prev) => !prev); + }; + + return ( + + + + }> + + + Beta + + + + + {generalSentiment} + + + + + {t('__SENTIMENT_COPY_BUTTON_LABEL')} + + + + {t('__SENTIMENT_OVERVIEW_ALERT_DISCLAIMER')} + + + + + ); +}; diff --git a/src/pages/VideoShared/components/TitleDropdownNew.tsx b/src/pages/VideoShared/components/TitleDropdownNew.tsx new file mode 100644 index 000000000..f402ccdd8 --- /dev/null +++ b/src/pages/VideoShared/components/TitleDropdownNew.tsx @@ -0,0 +1,120 @@ +import { + Autocomplete, + DropdownFieldNew as Field, +} from '@appquality/unguess-design-system'; +import { FormikProps } from 'formik'; +import { ComponentProps, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import analytics from 'src/analytics'; +import { ReactComponent as CopyIcon } from 'src/assets/icons/copy-icon.svg'; +import { + GetCampaignsByCidVideoTagsApiResponse, + usePostCampaignsByCidVideoTagsMutation, +} from 'src/features/api'; + +export interface ObservationFormValues { + title: number; + severity: number; + notes: string; + quotes?: string; +} + +export const TitleDropdown = ({ + titles, + formProps, +}: { + titles?: GetCampaignsByCidVideoTagsApiResponse[number]['tags']; + formProps: FormikProps; +}) => { + const { t } = useTranslation(); + const { campaignId } = useParams(); + const [addVideoTags] = usePostCampaignsByCidVideoTagsMutation(); + const titleMaxLength = 70; + const options: ComponentProps['options'] = useMemo( + () => + (titles || []).map((i) => ({ + id: i.id.toString(), + value: i.id.toString(), + children: `${i.name} (${i.usageNumber})`, + label: i.name, + isSelected: formProps.values.title === i.id, + itemID: i.id.toString(), + onOptionActionClick: () => { + analytics.track('tagEditModalOpened', { + tagId: i.id.toString(), + tagType: 'theme', + tagName: i.name, + associatedObservations: i.usageNumber, + }); + }, + })), + [titles, formProps.values.title] + ); + + if (!titles) { + return null; + } + + return ( + + { + if (!selection) return ''; + // @ts-ignore + const title = titles.find((i) => i.id === Number(selection.value)); + return title?.name || ''; + }} + onClick={() => + analytics.track('tagDropdownOpened', { + dropdownType: 'theme', + availableTagsCount: options.length, + }) + } + selectionValue={formProps.values.title.toString()} + onCreateNewOption={async (value) => { + if (value.length > titleMaxLength) { + formProps.setErrors({ + title: t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_MAX_ERROR' + ), + }); + return false; + } + + const res = await addVideoTags({ + cid: campaignId?.toString() || '0', + body: { + group: { + name: 'title', + }, + tag: { + name: value, + }, + }, + }).unwrap(); + formProps.setFieldValue('title', Number(res.tag.id)); + + return { + id: res.tag.id.toString(), + value: res.tag.id.toString(), + label: res.tag.name, + }; + }} + onOptionClick={({ inputValue, selectionValue }) => { + if (!selectionValue || !inputValue) return; + formProps.setFieldValue('title', Number(selectionValue)); + }} + startIcon={} + placeholder={t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_PLACEHOLDER' + )} + /> + + ); +}; diff --git a/src/pages/VideoShared/components/Transcript/EmptyState.tsx b/src/pages/VideoShared/components/Transcript/EmptyState.tsx new file mode 100644 index 000000000..1553bad56 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/EmptyState.tsx @@ -0,0 +1,41 @@ +import { SM } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as EmptyTranscriptImage } from 'src/assets/empty-transcript.svg'; +import styled from 'styled-components'; + +const EmptyTranscriptContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + margin-top: ${({ theme }) => theme.space.md}; + position: relative; +`; + +const StyledParagraph = styled(SM)` + text-align: center; + margin-left: ${({ theme }) => theme.space.lg}; + color: ${({ theme }) => theme.palette.grey[700]}; + bottom: ${({ theme }) => theme.space.xxl}; + font-weight: ${({ theme }) => theme.fontWeights.semibold}; +`; +const ImageContainer = styled.div` + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +`; + +export const EmptyState = () => { + const { t } = useTranslation(); + return ( + + + + + {t('__VIDEO_PAGE_TRANSCRIPT_EMPTY_STATE')} + + + + ); +}; diff --git a/src/pages/VideoShared/components/Transcript/Header.tsx b/src/pages/VideoShared/components/Transcript/Header.tsx new file mode 100644 index 000000000..af785a5ee --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/Header.tsx @@ -0,0 +1,71 @@ +import { SM, Transcript, XL } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import { FEATURE_FLAG_TAGGING_TOOL } from 'src/constants'; +import { useFeatureFlag } from 'src/hooks/useFeatureFlag'; +import styled from 'styled-components'; +import { Tools } from '../tools'; +import { TranslationLoader } from './TranslationLoader'; + +export const TranscriptHeader = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.md}; + margin-bottom: ${({ theme }) => theme.space.md}; + z-index: 200; +`; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: ${({ theme }) => theme.space.xs}; +`; + +const IconTitleContainer = styled.div` + display: flex; + align-items: flex-start; + gap: ${({ theme }) => theme.space.xxs}; +`; + +const ActionsWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +type Editor = React.ComponentProps['editor']; + +export const Header = ({ + editor, + isEmpty, +}: { + editor?: Editor; + isEmpty?: boolean; +}) => { + const { t } = useTranslation(); + const { hasFeatureFlag } = useFeatureFlag(); + return ( + + + + + {t('__VIDEO_PAGE_TRANSCRIPT_TITLE')} + + + + {t('__VIDEO_PAGE_TRANSCRIPT_INFO')} + + + {editor && !isEmpty ? ( + + + + + {hasFeatureFlag(FEATURE_FLAG_TAGGING_TOOL) && } + + ) : null} + + + ); +}; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/ActiveWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ActiveWrapper.tsx new file mode 100644 index 000000000..8a2e4d91a --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ActiveWrapper.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +const ActiveWrapper = styled.span` + background-color: ${({ theme }) => theme.palette.fuschia[400]}66; + display: inline-block; +`; + +const Component = ({ children }: { children: ReactNode }) => ( + {children} +); + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/ObservationWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ObservationWrapper.tsx new file mode 100644 index 000000000..594c2198f --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ObservationWrapper.tsx @@ -0,0 +1,64 @@ +import { Tooltip } from '@appquality/unguess-design-system'; +import { styled } from 'styled-components'; +import { ObservationTooltip } from '../../ObservationTooltip'; + +const TagWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + gap: 8px; +`; + +const ObservationWrapper = ({ + title, + color, + children, + observations, +}: { + title: string; + color: string; + children: React.ReactNode; + observations: { + start: number; + id: number; + title: string; + color: string; + }[]; +}) => { + const background = `${color}33`; + return ( + + + {observations.map((o) => ( + + + + ))} + + } + > + {children} + + + ); +}; + +export default ObservationWrapper; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/ParagraphWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ParagraphWrapper.tsx new file mode 100644 index 000000000..8c8cefb4c --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/ParagraphWrapper.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +const ComponentContainer = ({ + className, + children, +}: { + className: string; + children: ReactNode; +}) => ( + + {children} + +); + +const Component = styled(ComponentContainer)` + padding: ${({ theme }) => theme.space.sm} 0; + .paragraph-topbar { + align-items: center; + display: flex; + justify-content: space-between; + } +`; + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentenceWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentenceWrapper.tsx new file mode 100644 index 000000000..871309b36 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentenceWrapper.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react'; +import { styled } from 'styled-components'; + +const Wrapper = styled.span<{ isActive?: boolean }>` + ${({ isActive, theme }) => + isActive ? `background:${theme.palette.fuschia[400]}66;` : ``} + line-height: 2; + display: inline-block; + margin-right: ${({ theme }) => theme.space.xxs}; +`; + +const Component = ({ + setCurrentTime, + children, + start, + end, + isActive, +}: { + // eslint-disable-next-line no-shadow + setCurrentTime?: ({ start, end }: { start: number; end: number }) => void; + children: ReactNode; + start: number; + end: number; + isActive?: boolean; +}) => ( + { + if (setCurrentTime) { + setCurrentTime({ start, end }); + } + }} + isActive={isActive} + > + {children} + +); + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentencesWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentencesWrapper.tsx new file mode 100644 index 000000000..43aea65f8 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentencesWrapper.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +const Wrapper = styled.div` + margin-bottom: ${({ theme }) => theme.space.sm}; + font-size: ${({ theme }) => theme.fontSizes.md}; + position: relative; + color: ${({ theme }) => theme.palette.blue[600]}; + line-height: 2; + font-style: italic; + user-select: none; +`; + +const Component = ({ children }: { children: ReactNode }) => ( + {children} +); + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentimentWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentimentWrapper.tsx new file mode 100644 index 000000000..f7398d5a0 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SentimentWrapper.tsx @@ -0,0 +1,149 @@ +import { + Button, + IconButton, + MD, + SM, + Tag, + TooltipModal, +} from '@appquality/unguess-design-system'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as LeafIcon } from 'src/assets/icons/ai-icon.svg'; +import { ReactComponent as CopyIcon } from 'src/assets/icons/copy-icon.svg'; +import { ReactComponent as InfoIcon } from 'src/assets/icons/info.svg'; +import { useCopy } from 'src/hooks/useCopy'; +import { styled, useTheme } from 'styled-components'; +import { useToolsContext } from '../../tools/context/ToolsContext'; +import { ReactComponent as NegativeIcon } from '../assets/negative.svg'; +import { ReactComponent as NeutralIcon } from '../assets/neutral.svg'; +import { ReactComponent as PositiveIcon } from '../assets/positive.svg'; +import { ReactComponent as VeryNegativeIcon } from '../assets/very_negative.svg'; +import { ReactComponent as VeryPositiveIcon } from '../assets/very_positive.svg'; + +const useTagData = (value: number) => { + const { t } = useTranslation(); + const theme = useTheme(); + + switch (value) { + case 1: + return { + text: t('__TRANSCRIPT_SENTIMENT_VALUE_VERY_NEGATIVE'), + color: theme.palette.red[800], + textColor: theme.palette.red[100], + titleColor: theme.palette.red[900], + Icon: VeryNegativeIcon, + }; + case 2: + return { + text: t('__TRANSCRIPT_SENTIMENT_VALUE_NEGATIVE'), + color: theme.palette.red[100], + textColor: theme.palette.red[800], + titleColor: theme.palette.red[900], + Icon: NegativeIcon, + }; + case 3: + return { + text: t('__TRANSCRIPT_SENTIMENT_VALUE_NEUTRAL'), + color: 'transparent', + textColor: theme.palette.grey[600], + titleColor: theme.palette.grey[800], + Icon: NeutralIcon, + }; + case 4: + return { + text: t('__TRANSCRIPT_SENTIMENT_VALUE_POSITIVE'), + color: theme.palette.green[10], + textColor: theme.palette.green[600], + titleColor: theme.palette.green[800], + Icon: PositiveIcon, + }; + case 5: + return { + text: t('__TRANSCRIPT_SENTIMENT_VALUE_VERY_POSITIVE'), + color: theme.palette.green[800], + textColor: theme.palette.green[50], + titleColor: theme.palette.green[800], + Icon: VeryPositiveIcon, + }; + default: + return { + text: '', + color: 'grey', + textColor: 'white', + titleColor: 'grey', + Icon: LeafIcon, + }; + } +}; + +const StyledSM = styled(SM)` + color: ${({ theme }) => theme.palette.grey[600]}; +`; + +const TagWrapper = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.space.xxs}; +`; + +const Component = ({ value, text }: { value: number; text: string }) => { + const tagData = useTagData(value); + const { t } = useTranslation(); + const { showSentiment } = useToolsContext(); + + const copy = useCopy({ + text, + notification: t('__SENTIMENT_TOAST_COPY_MESSAGE'), + }); + const theme = useTheme(); + const { Icon } = tagData; + const ref = useRef(null); + const [referenceElement, setReferenceElement] = + useState(null); + + if (!showSentiment) return ; + + return ( + <> + + + + + + {tagData.text} + + setReferenceElement(ref.current)} + > + + + + setReferenceElement(null)} + > + + {tagData.text} + + + {text} + + + + copy()} isBasic> + + + + {t('__SENTIMENT_COPY_BUTTON_LABEL')} + + + + + > + ); +}; + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/SpeakerWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SpeakerWrapper.tsx new file mode 100644 index 000000000..3c395475e --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/SpeakerWrapper.tsx @@ -0,0 +1,59 @@ +import { Button, SM, useVideoContext } from '@appquality/unguess-design-system'; +import { ReactComponent as PlayIcon } from 'src/assets/icons/play-fill.svg'; +import { formatDuration } from 'src/pages/Videos/utils/formatDuration'; +import { styled } from 'styled-components'; + +const Wrapper = styled.div` + padding: ${({ theme }) => theme.space.xs} 0; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.space.xs}; +`; +const ButtonWrapper = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.space.xs}; +`; + +const SpeakerWrapper = ({ + start, + end, + setCurrentTime, + speaker, + totalSpeakers, +}: { + start: number; + end: number; + setCurrentTime?: (time: { start: number; end: number }) => void; + speaker: number; + totalSpeakers: number | null; +}) => { + const { context, setIsPlaying } = useVideoContext(); + return ( + + {totalSpeakers && totalSpeakers > 1 ? ( + Speaker {speaker + 1} + ) : null} + { + setCurrentTime({ start, end }); + context.player?.ref?.current?.play(); + setIsPlaying(true); + } + : undefined + } + > + + + {formatDuration(start)} + + + + ); +}; + +export default SpeakerWrapper; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/TranslationsWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/TranslationsWrapper.tsx new file mode 100644 index 000000000..c62482706 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/TranslationsWrapper.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; + +const Component = ({ + content, + translations, +}: { + content: ReactNode; + translations: ReactNode; +}) => ( + + {content} + {translations} + +); + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/WordWrapper.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/WordWrapper.tsx new file mode 100644 index 000000000..dce4cdaa4 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/WordWrapper.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; + +const WordWrapper = styled.span` + display: inline-block; + font-size: ${({ theme }) => theme.fontSizes.md}; + position: relative; + color: ${({ theme }) => theme.palette.grey[700]}; + line-height: 2; +`; + +const Component = ({ children }: { children: ReactNode }) => ( + {children} +); + +export default Component; diff --git a/src/pages/VideoShared/components/Transcript/TranscriptTheme/index.tsx b/src/pages/VideoShared/components/Transcript/TranscriptTheme/index.tsx new file mode 100644 index 000000000..8db22e7fb --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranscriptTheme/index.tsx @@ -0,0 +1,33 @@ +import { Theme } from '@appquality/unguess-design-system'; +import { styled } from 'styled-components'; +import ActiveWrapper from './ActiveWrapper'; +import ObservationWrapper from './ObservationWrapper'; +import ParagraphWrapper from './ParagraphWrapper'; +import SentencesWrapper from './SentencesWrapper'; +import SentenceWrapper from './SentenceWrapper'; +import SentimentWrapper from './SentimentWrapper'; +import SpeakerWrapper from './SpeakerWrapper'; +import TranslationWrapper from './TranslationsWrapper'; + +export const TranscriptTheme = Theme.configure({ + speakerWrapper: SpeakerWrapper, + activeWrapper: ActiveWrapper, + observationWrapper: ObservationWrapper, + sentimentWrapper: SentimentWrapper, + searchStyleWrapper: styled.div` + .search-result { + background-color: ${({ theme }) => theme.palette.talk[600]}; + } + word { + display: inline-block; + font-size: ${({ theme }) => theme.fontSizes.md}; + position: relative; + color: ${({ theme }) => theme.palette.grey[700]}; + line-height: 2; + } + `, + sentencesWrapper: SentencesWrapper, + sentenceWrapper: SentenceWrapper, + translationWrapper: TranslationWrapper, + paragraphWrapper: ParagraphWrapper, +}); diff --git a/src/pages/VideoShared/components/Transcript/TranslationLoader.tsx b/src/pages/VideoShared/components/Transcript/TranslationLoader.tsx new file mode 100644 index 000000000..2fa131ebf --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/TranslationLoader.tsx @@ -0,0 +1,36 @@ +import { Skeleton, SM } from '@appquality/unguess-design-system'; +import { appTheme } from 'src/app/theme'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { useTranslationTools } from '../tools/hooks/useTranslationTools'; + +const LoaderWrapper = styled.div` + margin-top: ${appTheme.space.xs}; +`; + +export const TranslationLoader = () => { + const { isProcessing } = useTranslationTools(); + const { t } = useTranslation(); + + if (!isProcessing) return null; + + return ( + + + {t('__TOOLS_TRANSLATE_PROGRESS_BAR_LABEL')} + + + + ); +}; diff --git a/src/pages/VideoShared/components/Transcript/assets/negative.svg b/src/pages/VideoShared/components/Transcript/assets/negative.svg new file mode 100644 index 000000000..3a3eb0852 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/assets/negative.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/VideoShared/components/Transcript/assets/neutral.svg b/src/pages/VideoShared/components/Transcript/assets/neutral.svg new file mode 100644 index 000000000..a2624c137 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/assets/neutral.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/VideoShared/components/Transcript/assets/positive.svg b/src/pages/VideoShared/components/Transcript/assets/positive.svg new file mode 100644 index 000000000..c57384a9f --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/assets/positive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/VideoShared/components/Transcript/assets/very_negative.svg b/src/pages/VideoShared/components/Transcript/assets/very_negative.svg new file mode 100644 index 000000000..e7ddb844c --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/assets/very_negative.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/VideoShared/components/Transcript/assets/very_positive.svg b/src/pages/VideoShared/components/Transcript/assets/very_positive.svg new file mode 100644 index 000000000..6f7c70678 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/assets/very_positive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/VideoShared/components/Transcript/index.tsx b/src/pages/VideoShared/components/Transcript/index.tsx new file mode 100644 index 000000000..19388f329 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/index.tsx @@ -0,0 +1,82 @@ +import { + ContainerCard, + Skeleton, + Transcript as TranscriptComponent, +} from '@appquality/unguess-design-system'; +import { ReactNode } from 'react'; +import { appTheme } from 'src/app/theme'; +import { useGetVideosByVidQuery } from 'src/features/api'; +import { useTranslationTools } from '../tools/hooks/useTranslationTools'; +import { EmptyState } from './EmptyState'; +import { Header } from './Header'; +import { TranscriptTheme } from './TranscriptTheme'; +import { useContent } from './useContent'; +import { useObservations } from './useObservations'; + +const TranscriptWrapper = ({ + videoId, + children, + editor, +}: { + editor: any; + videoId?: string; + children: ReactNode; +}) => { + const { data: video } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + const isEmpty = !video?.transcript; + + return ( + + + + {video?.transcript ? children : } + + + ); +}; + +export const Transcript = ({ + videoId, + currentTime, + setCurrentTime, +}: { + videoId?: string; + currentTime: number; + setCurrentTime: (time: number) => void; +}) => { + const { data: content, speakers, sentiments } = useContent(videoId || ''); + const { data: observations } = useObservations(videoId || ''); + const { data: translationData } = useTranslationTools(); + + const editor = TranscriptComponent.useEditor( + { + currentTime: currentTime * 1000, + onSetCurrentTime: (time) => setCurrentTime(time), + content, + translations: translationData.translation?.sentences, + themeExtension: TranscriptTheme, + observations, + sentiments, + numberOfSpeakers: speakers || undefined, + }, + [observations, translationData.translation?.sentences] + ); + + return ( + + {editor ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/pages/VideoShared/components/Transcript/useContent.tsx b/src/pages/VideoShared/components/Transcript/useContent.tsx new file mode 100644 index 000000000..099ca0975 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/useContent.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { useGetVideosByVidQuery } from 'src/features/api'; + +export const useContent = (videoId: string) => { + const { + data: video, + isError: isErrorVideo, + isFetching: isFetchingVideo, + isLoading: isLoadingVideo, + } = useGetVideosByVidQuery({ + vid: videoId || '', + }); + + const content = useMemo( + () => + video && video?.transcript + ? video.transcript.paragraphs.map((p) => ({ + ...p, + speaker: p.speaker ? p.speaker : 0, + })) + : undefined, + [video] + ); + + const sentiments = useMemo( + () => + video && video?.sentiment + ? video.sentiment.paragraphs.map((s) => ({ + ...s, + text: s.reason, + })) + : undefined, + [video] + ); + + const speakers = useMemo(() => video?.transcript?.speakers || null, [video]); + + return { + data: content, + sentiments, + generalSentiment: video?.sentiment?.reason, + speakers, + isError: isErrorVideo, + isFetching: isFetchingVideo, + isLoading: isLoadingVideo, + }; +}; diff --git a/src/pages/VideoShared/components/Transcript/useObservations.tsx b/src/pages/VideoShared/components/Transcript/useObservations.tsx new file mode 100644 index 000000000..552a3f537 --- /dev/null +++ b/src/pages/VideoShared/components/Transcript/useObservations.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { appTheme } from 'src/app/theme'; +import { useGetVideosByVidObservationsQuery } from 'src/features/api'; + +export const useObservations = (videoId: string) => { + const { + data: observations, + isError, + isFetching, + isLoading, + } = useGetVideosByVidObservationsQuery({ + vid: videoId, + }); + + function isHexColor(color: string): color is `#${string}` { + return /^#[0-9A-F]{6}$/i.test(color); + } + + const observationList = useMemo( + () => + observations?.map((o) => { + const defaultColor = appTheme.palette.grey[600] as `#${string}`; + + const color = + o.tags.find((tag) => tag.group.name.toLowerCase() === 'severity')?.tag + .style || defaultColor; + + return { + id: o.id, + title: o.title, + text: o.title, + type: '', + start: o.start, + end: o.end, + color: isHexColor(color) ? color : defaultColor, + }; + }), + [observations] + ); + + return { + data: observationList, + isError, + isFetching, + isLoading, + }; +}; diff --git a/src/pages/VideoShared/components/context.tsx b/src/pages/VideoShared/components/context.tsx new file mode 100644 index 000000000..78a2ee71c --- /dev/null +++ b/src/pages/VideoShared/components/context.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useMemo, useState } from 'react'; + +interface TooltipModalContextType { + modalRef: HTMLDivElement | null; + setModalRef: (ref: HTMLDivElement | null) => void; +} + +const TooltipModalContext = createContext(null); + +export const TooltipModalContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [modalRef, setModalRef] = + useState(null); + + const TooltipModalContextValue = useMemo( + () => ({ + modalRef, + setModalRef, + }), + [modalRef, setModalRef] + ); + + return ( + + {children} + + ); +}; + +export const useTooltipModalContext = () => { + const context = useContext(TooltipModalContext); + + if (!context) + throw new Error('Provider not found for TooltipModalContextProvider'); + + return context; // Now we can use the context in the component, SAFELY. +}; diff --git a/src/pages/VideoShared/components/tools/assets/showSentimentIcon.svg b/src/pages/VideoShared/components/tools/assets/showSentimentIcon.svg new file mode 100644 index 000000000..807e9e869 --- /dev/null +++ b/src/pages/VideoShared/components/tools/assets/showSentimentIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/pages/VideoShared/components/tools/context/ToolsContext.tsx b/src/pages/VideoShared/components/tools/context/ToolsContext.tsx new file mode 100644 index 000000000..3713ea70b --- /dev/null +++ b/src/pages/VideoShared/components/tools/context/ToolsContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useMemo, useState } from 'react'; + +interface ToolsContextType { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + language: string; + setLanguage: (lang: string) => void; + showSentiment: boolean; + setShowSentiment: (showSentiment: boolean) => void; +} + +const ToolsContext = createContext(null); + +export const ToolsContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [language, setLanguage] = useState(''); + const [showSentiment, setShowSentiment] = useState(true); + + const toolsContextValue = useMemo( + () => ({ + language, + setLanguage, + isOpen, + setIsOpen, + showSentiment, + setShowSentiment, + }), + [language, setLanguage, isOpen, setIsOpen, showSentiment, setShowSentiment] + ); + + return ( + + {children} + + ); +}; + +export const useToolsContext = () => { + const context = useContext(ToolsContext); + + if (!context) throw new Error('Provider not found for ToolsContextProvider'); + + return context; // Now we can use the context in the component, SAFELY. +}; diff --git a/src/pages/VideoShared/components/tools/hooks/useRequestTranslation.tsx b/src/pages/VideoShared/components/tools/hooks/useRequestTranslation.tsx new file mode 100644 index 000000000..b1f49968e --- /dev/null +++ b/src/pages/VideoShared/components/tools/hooks/useRequestTranslation.tsx @@ -0,0 +1,51 @@ +import { Notification, useToast } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { usePostVideosByVidTranslationMutation } from 'src/features/api'; +import { useToolsContext } from '../context/ToolsContext'; + +export const useRequestTranslation = ({ + language, + videoId, +}: { + language?: string; + videoId: string; +}) => { + const { t } = useTranslation(); + const { addToast } = useToast(); + const { setLanguage } = useToolsContext(); + + const [requestTranslation] = usePostVideosByVidTranslationMutation(); + if (!language) return () => null; + + const translate = () => { + requestTranslation({ + vid: videoId || '', + body: { + language, + }, + }) + .unwrap() + .then(() => { + setLanguage(language); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error(e); + + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + }); + }; + + return translate; +}; diff --git a/src/pages/VideoShared/components/tools/hooks/useTranslationTools.tsx b/src/pages/VideoShared/components/tools/hooks/useTranslationTools.tsx new file mode 100644 index 000000000..25d1cf55e --- /dev/null +++ b/src/pages/VideoShared/components/tools/hooks/useTranslationTools.tsx @@ -0,0 +1,83 @@ +import { useParams } from 'react-router-dom'; +import { FEATURE_FLAG_TAGGING_TOOL } from 'src/constants'; +import { useFeatureFlag } from 'src/hooks/useFeatureFlag'; +import { + GetVideosByVidTranslationApiResponse, + useGetVideosByVidQuery, + useGetVideosByVidTranslationQuery, +} from 'src/features/api'; +import { useEffect, useState } from 'react'; +import { usePreferredLanguage } from '../usePreferredLanguage'; +import { useToolsContext } from '../context/ToolsContext'; + +export const useTranslationTools = () => { + const { videoId } = useParams(); + const [data, setData] = useState<{ + hasQuickTranslate?: boolean; + preferredLanguage?: string; + translation?: GetVideosByVidTranslationApiResponse; + canQuickTranslate?: boolean; + }>({ + hasQuickTranslate: false, + canQuickTranslate: true, + }); + + const { hasFeatureFlag } = useFeatureFlag(); + const hasAIFeatureFlag = hasFeatureFlag(FEATURE_FLAG_TAGGING_TOOL); + const preferredLanguage = usePreferredLanguage(); + const { language } = useToolsContext(); + + const { + data: video, + isLoading: isLoadingVideo, + isError: isErrorVideo, + } = useGetVideosByVidQuery( + { + vid: videoId || '', + }, + { + skip: !videoId, + } + ); + + const { + data: translation, + isLoading: isLoadingTranslation, + isError: isErrorTranslation, + } = useGetVideosByVidTranslationQuery( + { + vid: videoId || '', + ...(language && { lang: language }), + }, + { + skip: !hasAIFeatureFlag || !videoId || !language, + } + ); + + useEffect(() => { + if (video) { + const hasQuickTranslate = + !!preferredLanguage && // Exists a preferred language + video.language.localeCompare(preferredLanguage) !== 0; // Video language is different from preferred language + + const isPrefTranslated = !!( + preferredLanguage && + translation?.language.localeCompare(preferredLanguage) === 0 + ); + + setData({ + hasQuickTranslate, + preferredLanguage, + canQuickTranslate: hasQuickTranslate && !isPrefTranslated, + translation, + }); + } + }, [video, translation, preferredLanguage]); + + return { + isError: !hasAIFeatureFlag || isErrorVideo || isErrorTranslation, + isProcessing: + isLoadingVideo || isLoadingTranslation || translation?.processing === 1, + data, + }; +}; diff --git a/src/pages/VideoShared/components/tools/index.tsx b/src/pages/VideoShared/components/tools/index.tsx new file mode 100644 index 000000000..3c80e44ec --- /dev/null +++ b/src/pages/VideoShared/components/tools/index.tsx @@ -0,0 +1,47 @@ +import { Button, Span } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { styled } from 'styled-components'; +import { useContent } from '../Transcript/useContent'; +import { ReactComponent as ShowSentimentIcon } from './assets/showSentimentIcon.svg'; +import { useToolsContext } from './context/ToolsContext'; +import { useTranslationTools } from './hooks/useTranslationTools'; + +const ShowHideSentiment = () => { + const { videoId } = useParams(); + const { t } = useTranslation(); + const { sentiments } = useContent(videoId || ''); + const { showSentiment, setShowSentiment } = useToolsContext(); + + if (!sentiments) return null; + + return ( + setShowSentiment(!showSentiment)}> + + + + + {showSentiment + ? t('__TOOLS_MENU_ITEM_HIDE_SENTIMENT_LABEL') + : t('__TOOLS_MENU_ITEM_SHOW_SENTIMENT_LABEL')} + + + ); +}; + +const ToolsWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.space.xs}; +`; + +export const Tools = () => { + const { isError } = useTranslationTools(); + + if (isError) return null; + + return ( + + + + ); +}; diff --git a/src/pages/VideoShared/components/tools/usePreferredLanguage.tsx b/src/pages/VideoShared/components/tools/usePreferredLanguage.tsx new file mode 100644 index 000000000..9af9ab99a --- /dev/null +++ b/src/pages/VideoShared/components/tools/usePreferredLanguage.tsx @@ -0,0 +1,17 @@ +import { getAllLanguageTags } from '@appquality/languages'; +import { useGetUsersMePreferencesQuery } from 'src/features/api'; + +export const usePreferredLanguage = () => { + const { data: preferences } = useGetUsersMePreferencesQuery(); + const languageTags = getAllLanguageTags(); + + const languagePreference = preferences?.items?.find( + (preference) => preference?.name === 'translations_language' + ); + + const preferredLanguage = languageTags.find( + (lang) => lang === languagePreference?.value + ); + + return preferredLanguage; +}; diff --git a/src/pages/VideoShared/context/VideoContext.tsx b/src/pages/VideoShared/context/VideoContext.tsx new file mode 100644 index 000000000..47723dde7 --- /dev/null +++ b/src/pages/VideoShared/context/VideoContext.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useMemo, useState } from 'react'; + +type VideoAccordion = { + id: number; +}; + +interface VideoContextType { + openAccordion: VideoAccordion | undefined; + setOpenAccordion: (accordion?: VideoAccordion) => void; +} + +const VideoContext = createContext(null); + +export const VideoContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [openAccordion, setOpenAccordion] = useState(); + + const videoContextValue = useMemo( + () => ({ + openAccordion, + setOpenAccordion, + }), + [openAccordion, setOpenAccordion] + ); + + return ( + + {children} + + ); +}; + +export const useVideoContext = () => { + const context = useContext(VideoContext); + + if (!context) throw new Error('Provider not found for VideoContextProvider'); + + return context; // Now we can use the context in the component, SAFELY. +}; diff --git a/src/pages/VideoShared/index.tsx b/src/pages/VideoShared/index.tsx new file mode 100644 index 000000000..f3c1d774d --- /dev/null +++ b/src/pages/VideoShared/index.tsx @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useAppDispatch } from 'src/app/hooks'; +import { FEATURE_FLAG_TAGGING_TOOL } from 'src/constants'; +import { useGetUsersMeQuery } from 'src/features/api'; +import { useGetCampaignWithWorkspaceQuery } from 'src/features/api/customEndpoints/getCampaignWithWorkspace'; +import { + setCampaignId, + setPermissionSettingsTitle, + setWorkspace, +} from 'src/features/navigation/navigationSlice'; +import { Page } from 'src/features/templates/Page'; +import { useCampaignAnalytics } from 'src/hooks/useCampaignAnalytics'; +import { useFeatureFlag } from 'src/hooks/useFeatureFlag'; +import { useLocalizeRoute } from 'src/hooks/useLocalizedRoute'; +import VideoPageHeader from './components/PageHeader'; +import VideoPageContent from './Content'; + +const VideoPageShared = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const notFoundRoute = useLocalizeRoute('oops'); + const { campaignId } = useParams(); + const dispatch = useAppDispatch(); + const location = useLocation(); + const { + isLoading: isUserLoading, + isFetching: isUserFetching, + isSuccess, + } = useGetUsersMeQuery(); + const { hasFeatureFlag } = useFeatureFlag(); + + const hasTaggingToolFeature = hasFeatureFlag(FEATURE_FLAG_TAGGING_TOOL); + + if (!campaignId || Number.isNaN(Number(campaignId))) { + navigate(notFoundRoute, { + state: { from: location.pathname }, + }); + } + + useCampaignAnalytics(campaignId); + + const { isError: isErrorCampaign, data: { campaign, workspace } = {} } = + useGetCampaignWithWorkspaceQuery( + { + cid: campaignId?.toString() ?? '0', + }, + { + skip: !campaignId, + } + ); + + useEffect(() => { + if (workspace) { + dispatch(setWorkspace(workspace)); + } + }, [workspace, dispatch]); + + useEffect(() => { + if (campaign) { + dispatch(setPermissionSettingsTitle(campaign.customer_title)); + dispatch(setCampaignId(campaign.id)); + } + + return () => { + dispatch(setPermissionSettingsTitle(undefined)); + dispatch(setCampaignId(undefined)); + }; + }, [campaign]); + + if (isErrorCampaign) { + navigate(notFoundRoute, { + state: { from: location.pathname }, + }); + } + + useEffect(() => { + if (isUserFetching || isUserLoading) return; + + if (!hasTaggingToolFeature && isSuccess) { + navigate(notFoundRoute, { + state: { from: location.pathname }, + }); + } + }, [isSuccess, isUserFetching, isUserLoading, hasTaggingToolFeature]); + + return ( + } + route="video" + excludeMarginTop + excludeMarginBottom + isMinimal + > + + + ); +}; + +export default VideoPageShared; diff --git a/src/pages/VideoShared/useSetStartTimeFromObservation.ts b/src/pages/VideoShared/useSetStartTimeFromObservation.ts new file mode 100644 index 000000000..fdc753a7f --- /dev/null +++ b/src/pages/VideoShared/useSetStartTimeFromObservation.ts @@ -0,0 +1,32 @@ +import { useEffect, useMemo } from 'react'; + +/** + * Sets the video player's currentTime based on the observation anchor in the URL. + * @param observations List of observations (array of objects with id and start) + * @param videoRef Ref to the HTMLVideoElement + * @returns startTime (number) + */ +export function useSetStartTimeFromObservation( + observations: { id: number; start: number }[] | undefined, + videoRef: React.RefObject +) { + const startTime = useMemo(() => { + const url = window.location.href; + const urlAnchor = url.split('#')[1]; + if (urlAnchor) { + const observationId = parseInt(urlAnchor.replace('observation-', ''), 10); + return observations?.find((obs) => obs.id === observationId)?.start || 0; + } + return 0; + }, [observations]); + + // Effect to set the video player's currentTime based only on the startTime, regardless of observations changing + useEffect(() => { + if (!videoRef.current) return; + if (startTime > 0) { + videoRef.current.currentTime = startTime; + } + }, [startTime, videoRef.current]); + + return startTime; +}