From 09277c264c02bbbcd89c58fd8592955b85e376a8 Mon Sep 17 00:00:00 2001 From: Abhirup Basu Date: Mon, 22 Jul 2024 23:11:01 +0530 Subject: [PATCH] :sparkles: Implement Pexels videos option to media popover (#1636) Closes #1575 Note: Need to create a new environment variable named `NEXT_PUBLIC_PEXELS_API_KEY` to store the API Key obtained from Pexels! https://github.com/user-attachments/assets/4250f799-0bd7-48e9-b9a8-4bc188ad7704 --------- Co-authored-by: Baptiste Arnaud Co-authored-by: younesbenallal --- apps/builder/package.json | 5 +- .../ImageUploadContent/GiphyPicker.tsx | 1 + .../VideoUploadContent/PexelsPicker.tsx | 254 ++++++++++++++++++ .../VideoLinkEmbedContent.tsx | 110 ++++++++ .../src/components/logos/GiphyLogo.tsx | 4 +- .../src/components/logos/PexelsLogo.tsx | 25 ++ .../video/components/VideoUploadContent.tsx | 162 +++++------ apps/docs/self-hosting/configuration.mdx | 8 + packages/env/env.ts | 13 + pnpm-lock.yaml | 23 +- 10 files changed, 507 insertions(+), 98 deletions(-) create mode 100644 apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx create mode 100644 apps/builder/src/components/VideoUploadContent/VideoLinkEmbedContent.tsx create mode 100644 apps/builder/src/components/logos/PexelsLogo.tsx diff --git a/apps/builder/package.json b/apps/builder/package.json index 04a3215b8..83d37f0ad 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -82,6 +82,7 @@ "nprogress": "0.2.0", "openai": "4.47.1", "papaparse": "5.4.1", + "pexels": "^1.4.0", "prettier": "2.8.8", "qs": "6.11.2", "react": "18.2.0", @@ -113,6 +114,7 @@ "@typebot.io/schemas": "workspace:*", "@typebot.io/telemetry": "workspace:*", "@typebot.io/tsconfig": "workspace:*", + "@typebot.io/variables": "workspace:*", "@types/canvas-confetti": "1.6.0", "@types/jsonwebtoken": "9.0.2", "@types/micro-cors": "0.1.3", @@ -131,7 +133,6 @@ "next-runtime-env": "1.6.2", "superjson": "1.12.4", "typescript": "5.4.5", - "zod": "3.22.4", - "@typebot.io/variables": "workspace:*" + "zod": "3.22.4" } } diff --git a/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx b/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx index 798f63775..f94eb74ca 100644 --- a/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx +++ b/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx @@ -31,6 +31,7 @@ export const GiphyPicker = ({ onSubmit }: GiphySearchFormProps) => { placeholder="Search..." onChange={setInputValue} withVariableButton={false} + width="full" /> diff --git a/apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx b/apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx new file mode 100644 index 000000000..a29c85bba --- /dev/null +++ b/apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx @@ -0,0 +1,254 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + Alert, + AlertIcon, + Box, + Flex, + Grid, + GridItem, + HStack, + Image, + Link, + Spinner, + Stack, + Text, +} from '@chakra-ui/react' +import { isDefined } from '@typebot.io/lib' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createClient, Video, ErrorResponse, Videos } from 'pexels' +import { TextInput } from '../inputs' +import { TextLink } from '../TextLink' +import { env } from '@typebot.io/env' +import { PexelsLogo } from '../logos/PexelsLogo' + +const client = createClient(env.NEXT_PUBLIC_PEXELS_API_KEY ?? 'dummy') + +type Props = { + videoSize: 'large' | 'medium' | 'small' + onVideoSelect: (videoUrl: string) => void +} + +export const PexelsPicker = ({ videoSize, onVideoSelect }: Props) => { + const [isFetching, setIsFetching] = useState(false) + const [videos, setVideos] = useState([]) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const scrollContainer = useRef(null) + const bottomAnchor = useRef(null) + + const [nextPage, setNextPage] = useState(0) + + const fetchNewVideos = useCallback(async (query: string, page: number) => { + if (query === '') getInitialVideos() + if (query.length <= 2) { + setNextPage(0) + return + } + setError(null) + setIsFetching(true) + try { + const result = await client.videos.search({ + query, + per_page: 24, + size: videoSize, + page, + }) + if ((result as ErrorResponse).error) + setError((result as ErrorResponse).error) + if (isDefined((result as Videos).videos)) { + if (page === 0) setVideos((result as Videos).videos) + else + setVideos((videos) => [ + ...videos, + ...((result as Videos)?.videos ?? []), + ]) + setNextPage((page) => page + 1) + } + } catch (err) { + if (err && typeof err === 'object' && 'message' in err) + setError(err.message as string) + setError('Something went wrong') + } + setIsFetching(false) + }, []) + + useEffect(() => { + if (!bottomAnchor.current) return + const observer = new IntersectionObserver( + (entities: IntersectionObserverEntry[]) => { + const target = entities[0] + if (target.isIntersecting) fetchNewVideos(searchQuery, nextPage + 1) + }, + { + root: scrollContainer.current, + } + ) + if (bottomAnchor.current && nextPage > 0) + observer.observe(bottomAnchor.current) + return () => { + observer.disconnect() + } + }, [fetchNewVideos, nextPage, searchQuery]) + + const getInitialVideos = async () => { + setError(null) + setIsFetching(true) + client.videos + .popular({ + per_page: 24, + size: videoSize, + }) + .then((res) => { + if ((res as ErrorResponse).error) { + setError((res as ErrorResponse).error) + } + setVideos((res as Videos).videos) + setIsFetching(false) + }) + .catch((err) => { + if (err && typeof err === 'object' && 'message' in err) + setError(err.message as string) + setError('Something went wrong') + setIsFetching(false) + }) + } + + const selectVideo = (video: Video) => { + const videoUrl = video.video_files[0].link + if (isDefined(videoUrl)) onVideoSelect(videoUrl) + } + + useEffect(() => { + if (!env.NEXT_PUBLIC_PEXELS_API_KEY) return + getInitialVideos() + }, []) + + if (!env.NEXT_PUBLIC_PEXELS_API_KEY) + return NEXT_PUBLIC_PEXELS_API_KEY is missing in environment + + return ( + + + { + setSearchQuery(query) + fetchNewVideos(query, 0) + }} + withVariableButton={false} + debounceTimeout={500} + forceDebounce + width="full" + /> + + + + + {isDefined(error) && ( + + + {error} + + )} + + {videos.length > 0 && ( + + {videos.map((video, index) => ( + + selectVideo(video)} /> + + ))} + + )} + {isFetching && ( + + + + )} + + + ) +} + +type PexelsVideoProps = { + video: Video + onClick: () => void +} + +const PexelsVideo = ({ video, onClick }: PexelsVideoProps) => { + const { user, url, video_pictures } = video + const [isImageHovered, setIsImageHovered] = useState(false) + const [thumbnailImage, setThumbnailImage] = useState( + video_pictures[0].picture + ) + const [imageIndex, setImageIndex] = useState(1) + + useEffect(() => { + let interval: NodeJS.Timer + + if (isImageHovered && video_pictures.length > 0) { + interval = setInterval(() => { + setImageIndex((prevIndex) => (prevIndex + 1) % video_pictures.length) + setThumbnailImage(video_pictures[imageIndex].picture) + }, 200) + } else { + setThumbnailImage(video_pictures[0].picture) + setImageIndex(1) + } + + return () => { + if (interval) { + clearInterval(interval) + } + } + }, [isImageHovered, imageIndex, video_pictures]) + + return ( + setIsImageHovered(true)} + onMouseLeave={() => setIsImageHovered(false)} + h="full" + > + { + {`Pexels + } + + + {user.name} + + + + ) +} diff --git a/apps/builder/src/components/VideoUploadContent/VideoLinkEmbedContent.tsx b/apps/builder/src/components/VideoUploadContent/VideoLinkEmbedContent.tsx new file mode 100644 index 000000000..2dda4fe9b --- /dev/null +++ b/apps/builder/src/components/VideoUploadContent/VideoLinkEmbedContent.tsx @@ -0,0 +1,110 @@ +import { Stack, Text } from '@chakra-ui/react' +import { useTranslate } from '@tolgee/react' +import { VideoBubbleBlock } from '@typebot.io/schemas' +import { TextInput } from '@/components/inputs' +import { defaultVideoBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/video/constants' +import { SwitchWithLabel } from '../inputs/SwitchWithLabel' + +export const VideoLinkEmbedContent = ({ + content, + updateUrl, + onSubmit, +}: { + content?: VideoBubbleBlock['content'] + updateUrl: (url: string) => void + onSubmit: (content: VideoBubbleBlock['content']) => void +}) => { + const { t } = useTranslate() + + const updateAspectRatio = (aspectRatio?: string) => { + return onSubmit({ + ...content, + aspectRatio, + }) + } + + const updateMaxWidth = (maxWidth?: string) => { + return onSubmit({ + ...content, + maxWidth, + }) + } + + const updateAutoPlay = (isAutoplayEnabled: boolean) => { + return onSubmit({ ...content, isAutoplayEnabled }) + } + + const updateControlsDisplay = (areControlsDisplayed: boolean) => { + if (areControlsDisplayed === false) { + // Make sure autoplay is enabled when video controls are disabled + return onSubmit({ + ...content, + isAutoplayEnabled: true, + areControlsDisplayed, + }) + } + return onSubmit({ ...content, areControlsDisplayed }) + } + + return ( + <> + + + + {t('video.urlInput.helperText')} + + + {content?.url && ( + + + + + )} + {content?.url && content?.type === 'url' && ( + + + updateAutoPlay(!content.isAutoplayEnabled)} + /> + + )} + + ) +} diff --git a/apps/builder/src/components/logos/GiphyLogo.tsx b/apps/builder/src/components/logos/GiphyLogo.tsx index 8e41c57f4..16ece7384 100644 --- a/apps/builder/src/components/logos/GiphyLogo.tsx +++ b/apps/builder/src/components/logos/GiphyLogo.tsx @@ -1,4 +1,4 @@ -import { IconProps, Icon } from '@chakra-ui/react' +import { IconProps, Icon, useColorModeValue } from '@chakra-ui/react' export const GiphyLogo = (props: IconProps) => ( @@ -15,7 +15,7 @@ export const GiphyLogo = (props: IconProps) => ( diff --git a/apps/builder/src/components/logos/PexelsLogo.tsx b/apps/builder/src/components/logos/PexelsLogo.tsx new file mode 100644 index 000000000..ddf50d1ea --- /dev/null +++ b/apps/builder/src/components/logos/PexelsLogo.tsx @@ -0,0 +1,25 @@ +import { IconProps, Icon, useColorModeValue } from '@chakra-ui/react' + +export const PexelsLogo = (props: IconProps) => ( + + + + + + + +) diff --git a/apps/builder/src/features/blocks/bubbles/video/components/VideoUploadContent.tsx b/apps/builder/src/features/blocks/bubbles/video/components/VideoUploadContent.tsx index 716c57742..5eb71b98a 100644 --- a/apps/builder/src/features/blocks/bubbles/video/components/VideoUploadContent.tsx +++ b/apps/builder/src/features/blocks/bubbles/video/components/VideoUploadContent.tsx @@ -1,18 +1,46 @@ -import { Stack, Text } from '@chakra-ui/react' +import { Button, HStack, Stack } from '@chakra-ui/react' import { VideoBubbleBlock } from '@typebot.io/schemas' -import { TextInput } from '@/components/inputs' -import { useTranslate } from '@tolgee/react' import { parseVideoUrl } from '@typebot.io/schemas/features/blocks/bubbles/video/helpers' -import { defaultVideoBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/video/constants' -import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' +import { useState } from 'react' +import { PexelsPicker } from '@/components/VideoUploadContent/PexelsPicker' +import { VideoLinkEmbedContent } from '@/components/VideoUploadContent/VideoLinkEmbedContent' + +type Tabs = 'link' | 'pexels' type Props = { content?: VideoBubbleBlock['content'] onSubmit: (content: VideoBubbleBlock['content']) => void -} + initialTab?: Tabs +} & ( + | { + includedTabs?: Tabs[] + } + | { + excludedTabs?: Tabs[] + } +) + +const defaultDisplayedTabs: Tabs[] = ['link', 'pexels'] + +export const VideoUploadContent = ({ + content, + onSubmit, + initialTab, + ...props +}: Props) => { + const includedTabs = + 'includedTabs' in props + ? props.includedTabs ?? defaultDisplayedTabs + : defaultDisplayedTabs + const excludedTabs = 'excludedTabs' in props ? props.excludedTabs ?? [] : [] + const displayedTabs = defaultDisplayedTabs.filter( + (tab) => !excludedTabs.includes(tab) && includedTabs.includes(tab) + ) + + const [currentTab, setCurrentTab] = useState( + initialTab ?? displayedTabs[0] + ) -export const VideoUploadContent = ({ content, onSubmit }: Props) => { - const { t } = useTranslate() const updateUrl = (url: string) => { const { type, @@ -20,6 +48,10 @@ export const VideoUploadContent = ({ content, onSubmit }: Props) => { id, videoSizeSuggestion, } = parseVideoUrl(url) + if (currentTab !== 'link') { + // Allow user to update video settings after selection + setCurrentTab('link') + } return onSubmit({ ...content, type, @@ -30,94 +62,40 @@ export const VideoUploadContent = ({ content, onSubmit }: Props) => { : {}), }) } - const updateAspectRatio = (aspectRatio?: string) => { - return onSubmit({ - ...content, - aspectRatio, - }) - } - - const updateMaxWidth = (maxWidth?: string) => { - return onSubmit({ - ...content, - maxWidth, - }) - } - - const updateAutoPlay = (isAutoplayEnabled: boolean) => { - return onSubmit({ ...content, isAutoplayEnabled }) - } - - const updateControlsDisplay = (areControlsDisplayed: boolean) => { - if (areControlsDisplayed === false) { - // Make sure autoplay is enabled when video controls are disabled - return onSubmit({ - ...content, - isAutoplayEnabled: true, - areControlsDisplayed, - }) - } - return onSubmit({ ...content, areControlsDisplayed }) - } return ( - - - + + {displayedTabs.includes('link') && ( + + )} + {displayedTabs.includes('pexels') && ( + + )} + + + {/* Body content to be displayed below conditionally based on currentTab */} + {currentTab === 'link' && ( + - - {t('video.urlInput.helperText')} - - - {content?.url && ( - - - - )} - {content?.url && content?.type === 'url' && ( - - - updateAutoPlay(!content.isAutoplayEnabled)} - /> - + {currentTab === 'pexels' && ( + )} ) diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx index 8129bd76a..f27ebe7fe 100644 --- a/apps/docs/self-hosting/configuration.mdx +++ b/apps/docs/self-hosting/configuration.mdx @@ -235,6 +235,14 @@ Used to search for images. You can create an Unsplash app [here](https://unsplas | NEXT_PUBLIC_UNSPLASH_APP_NAME | | Unsplash App name | | NEXT_PUBLIC_UNSPLASH_ACCESS_KEY | | Unsplash API key | +## Pexels (video picker) + +Used to search for videos. You can create a Pexels app [here](https://www.pexels.com/api/key/) + +| Parameter | Default | Description | +| -------------------------- | ------- | -------------- | +| NEXT_PUBLIC_PEXELS_API_KEY | | Pexels API key | + ## Tolgee (i18n contribution dev tool) diff --git a/packages/env/env.ts b/packages/env/env.ts index 6ae30632b..455d2efd5 100644 --- a/packages/env/env.ts +++ b/packages/env/env.ts @@ -322,6 +322,17 @@ const unsplashEnv = { }, } +const pexelsEnv = { + client: { + NEXT_PUBLIC_PEXELS_API_KEY: z.string().min(1).optional(), + }, + runtimeEnv: { + NEXT_PUBLIC_PEXELS_API_KEY: getRuntimeVariable( + 'NEXT_PUBLIC_PEXELS_API_KEY' + ), + }, +} + const whatsAppEnv = { server: { META_SYSTEM_USER_TOKEN: z.string().min(1).optional(), @@ -440,6 +451,7 @@ export const env = createEnv({ ...giphyEnv.client, ...vercelEnv.client, ...unsplashEnv.client, + ...pexelsEnv.client, ...sentryEnv.client, ...posthogEnv.client, ...tolgeeEnv.client, @@ -452,6 +464,7 @@ export const env = createEnv({ ...giphyEnv.runtimeEnv, ...vercelEnv.runtimeEnv, ...unsplashEnv.runtimeEnv, + ...pexelsEnv.runtimeEnv, ...sentryEnv.runtimeEnv, ...posthogEnv.runtimeEnv, ...tolgeeEnv.runtimeEnv, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab5b5f053..445fc6986 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: papaparse: specifier: 5.4.1 version: 5.4.1 + pexels: + specifier: ^1.4.0 + version: 1.4.0 prettier: specifier: 2.8.8 version: 2.8.8 @@ -969,7 +972,7 @@ importers: version: 2.8.8 ts-jest: specifier: 29.0.5 - version: 29.0.5(@babel/core@7.22.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.22.9))(jest@29.4.1(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.4.5) + version: 29.0.5(@babel/core@7.22.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.22.9))(esbuild@0.15.18)(jest@29.4.1(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.4.5) tsup: specifier: 6.5.0 version: 6.5.0(@swc/core@1.3.101)(postcss@8.4.35)(typescript@5.4.5) @@ -4083,6 +4086,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -4090,6 +4094,7 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead '@img/sharp-darwin-arm64@0.33.2': resolution: {integrity: sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==} @@ -8489,6 +8494,7 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -8497,6 +8503,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -8789,6 +8796,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -10607,6 +10615,9 @@ packages: periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + pexels@1.4.0: + resolution: {integrity: sha512-akpLySokCtw9JHGx7yMavOIAHGVP5721rLUONR/cFKjWkLjUXsHrJ5jndMKss9mx7AEMZRXs7loxEb+vLJf6kA==} + picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -11417,6 +11428,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup-plugin-postcss@4.0.2: @@ -24186,6 +24198,12 @@ snapshots: estree-walker: 3.0.3 is-reference: 3.0.2 + pexels@1.4.0: + dependencies: + isomorphic-fetch: 3.0.0 + transitivePeerDependencies: + - encoding + picocolors@1.0.0: {} picomatch@2.3.1: {} @@ -26026,7 +26044,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.0.5(@babel/core@7.22.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.22.9))(jest@29.4.1(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.4.5): + ts-jest@29.0.5(@babel/core@7.22.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.22.9))(esbuild@0.15.18)(jest@29.4.1(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -26042,6 +26060,7 @@ snapshots: '@babel/core': 7.22.9 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.22.9) + esbuild: 0.15.18 tsconfck@3.0.3(typescript@5.4.5): optionalDependencies: