2
0

Add Dashboard

This commit is contained in:
Baptiste Arnaud
2021-12-06 15:48:50 +01:00
parent 5e14a94dea
commit 54a641b819
47 changed files with 2002 additions and 168 deletions

View File

@ -1 +1,72 @@
import { IconProps, Icon } from '@chakra-ui/react'
const featherIconsBaseProps: IconProps = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2px',
strokeLinecap: 'round',
strokeLinejoin: 'round',
}
export const SettingsIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</Icon>
)
export const LogOutIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</Icon>
)
export const ChevronLeftIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="15 18 9 12 15 6"></polyline>
</Icon>
)
export const PlusIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</Icon>
)
export const FolderIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</Icon>
)
export const MoreVerticalIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</Icon>
)
export const GlobeIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</Icon>
)
export const ToolIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</Icon>
)
export const FolderPlusIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</Icon>
)

View File

@ -1,17 +1,12 @@
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { User } from '@typebot/prisma'
export type withAuthProps = {
user?: User
}
const withAuth =
(WrappedComponent: ({ user }: withAuthProps) => JSX.Element) =>
(props: JSX.IntrinsicAttributes & withAuthProps) => {
(WrappedComponent: (props: any) => JSX.Element) =>
(props: JSX.IntrinsicAttributes) => {
const router = useRouter()
const { data: session, status } = useSession()
const { status } = useSession()
useEffect(() => {
if (!router.isReady) return
@ -19,9 +14,7 @@ const withAuth =
if (status === 'unauthenticated') router.replace('/signin')
}, [status, router])
return (
<WrappedComponent user={session?.user as User | undefined} {...props} />
)
return <WrappedComponent {...props} />
}
export default withAuth

View File

@ -0,0 +1,28 @@
import {
ButtonProps,
IconButton,
Menu,
MenuButton,
MenuList,
} from '@chakra-ui/react'
import { MoreVerticalIcon } from 'assets/icons'
import { ReactNode } from 'react'
type Props = { children: ReactNode } & ButtonProps
export const MoreButton = ({ children, ...props }: Props) => {
return (
<Menu>
<MenuButton
as={IconButton}
icon={<MoreVerticalIcon />}
onClick={(e) => e.stopPropagation()}
colorScheme="blue"
variant="ghost"
size="lg"
{...props}
/>
<MenuList>{children}</MenuList>
</Menu>
)
}

View File

@ -0,0 +1,74 @@
import React from 'react'
import {
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
HStack,
Flex,
Avatar,
SkeletonCircle,
Skeleton,
} from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import { useUser } from 'services/user'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { LogOutIcon, SettingsIcon } from 'assets/icons'
import { signOut } from 'next-auth/react'
export const DashboardHeader = () => {
const user = useUser()
const handleLogOut = () => {
signOut()
}
return (
<Flex w="full" borderBottomWidth="1px" justify="center">
<Flex
justify="space-between"
alignItems="center"
h="16"
maxW="1000px"
flex="1"
>
<NextChakraLink
className="w-24"
href="/typebots"
data-testid="authenticated"
>
<TypebotLogo w="30px" />
</NextChakraLink>
<Menu>
<MenuButton>
<HStack>
<Skeleton isLoaded={user !== undefined}>
<Text>{user?.name}</Text>
</Skeleton>
<SkeletonCircle isLoaded={user !== undefined}>
<Avatar
boxSize="35px"
name={user?.name ?? undefined}
src={user?.image ?? undefined}
/>
</SkeletonCircle>
</HStack>
</MenuButton>
<MenuList>
<MenuItem
as={NextChakraLink}
href="/account"
icon={<SettingsIcon />}
>
My account
</MenuItem>
<MenuItem onClick={handleLogOut} icon={<LogOutIcon />}>
Log out
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,181 @@
import { DashboardFolder, Typebot } from '.prisma/client'
import {
Button,
Flex,
Heading,
HStack,
Skeleton,
Stack,
useToast,
Wrap,
} from '@chakra-ui/react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { FolderPlusIcon } from 'assets/icons'
import React, { useState } from 'react'
import { createFolder, useFolders } from 'services/folders'
import { updateTypebot, useTypebots } from 'services/typebots'
import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
type Props = { folder: DashboardFolder | null }
export const FolderContent = ({ folder }: Props) => {
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const [draggedTypebot, setDraggedTypebot] = useState<Typebot | undefined>()
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
delay: 100,
tolerance: 300,
},
})
)
const toast = useToast({
position: 'top-right',
status: 'error',
})
const {
folders,
isLoading: isFolderLoading,
mutate: mutateFolders,
} = useFolders({
parentId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch folders", description: error.message })
},
})
const {
typebots,
isLoading: isTypebotLoading,
mutate: mutateTypebots,
} = useTypebots({
folderId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch typebots", description: error.message })
},
})
const handleDragStart = (event: DragStartEvent) => {
if (!typebots) return
setDraggedTypebot(typebots.find((c) => c.id === event.active.id))
}
const handleDragEnd = async (event: DragEndEvent) => {
if (!typebots) return
const { over } = event
if (over?.id && draggedTypebot?.id)
await moveTypebotToFolder(draggedTypebot.id, over.id)
setDraggedTypebot(undefined)
}
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return
const { error } = await updateTypebot(typebotId, {
folderId: folderId === 'root' ? null : folderId,
})
if (error) toast({ description: error.message })
mutateTypebots({ typebots: typebots.filter((t) => t.id !== typebotId) })
}
const handleCreateFolder = async () => {
if (!folders) return
setIsCreatingFolder(true)
const { error, data: newFolder } = await createFolder({
parentFolderId: folder?.id ?? null,
})
setIsCreatingFolder(false)
if (error)
return toast({ title: 'An error occured', description: error.message })
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
}
const handleTypebotDeleted = (deletedId: string) => {
if (!typebots) return
mutateTypebots({ typebots: typebots.filter((t) => t.id !== deletedId) })
}
const handleFolderDeleted = (deletedId: string) => {
if (!folders) return
mutateFolders({ folders: folders.filter((f) => f.id !== deletedId) })
}
const handleFolderRenamed = (folderId: string, name: string) => {
if (!folders) return
mutateFolders({
folders: folders.map((f) => (f.id === folderId ? { ...f, name } : f)),
})
}
return (
<Flex w="full" justify="center" align="center" pt={4}>
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>
</Skeleton>
<Stack>
<HStack>
{folder && <BackButton id={folder.parentFolderId} />}
<Button
colorScheme="gray"
leftIcon={<FolderPlusIcon />}
onClick={handleCreateFolder}
isLoading={isCreatingFolder || isFolderLoading}
>
Create a folder
</Button>
</HStack>
<Wrap spacing={4}>
<CreateBotButton
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
{isFolderLoading && <ButtonSkeleton />}
{folders &&
folders.map((folder) => (
<FolderButton
key={folder.id.toString()}
folder={folder}
onFolderDeleted={() => handleFolderDeleted(folder.id)}
onFolderRenamed={(newName: string) =>
handleFolderRenamed(folder.id, newName)
}
/>
))}
{isTypebotLoading && <ButtonSkeleton />}
{typebots &&
typebots.map((typebot) => (
<TypebotButton
key={typebot.id.toString()}
typebot={typebot}
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
/>
))}
<DragOverlay dropAnimation={null}>
{draggedTypebot && (
<TypebotCardOverlay typebot={draggedTypebot} />
)}
</DragOverlay>
</Wrap>
</Stack>
</Stack>
</DndContext>
</Flex>
)
}

View File

@ -0,0 +1,24 @@
import { Button } from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { ChevronLeftIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import React from 'react'
export const BackButton = ({ id }: { id: string | null }) => {
const { setNodeRef, isOver } = useDroppable({
id: id?.toString() ?? 'root',
})
return (
<Button
as={NextChakraLink}
href={id ? `/typebots/folders/${id}` : '/typebots'}
leftIcon={<ChevronLeftIcon />}
variant={'outline'}
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
ref={setNodeRef}
>
Back
</Button>
)
}

View File

@ -0,0 +1,41 @@
import { Button, ButtonProps, Text, VStack } from '@chakra-ui/react'
import { PlusIcon } from 'assets/icons'
import { useRouter } from 'next/router'
import React from 'react'
export const CreateBotButton = ({
folderId,
...props
}: { folderId?: string } & ButtonProps) => {
const router = useRouter()
const handleClick = () =>
folderId
? router.push(`/typebots/create?folderId=${folderId}`)
: router.push('/typebots/create')
return (
<Button
mr={{ sm: 6 }}
mb={6}
style={{ width: '225px', height: '270px' }}
onClick={handleClick}
paddingX={6}
whiteSpace={'normal'}
{...props}
>
<VStack spacing="6">
<PlusIcon fontSize="40px" />
<Text
fontSize={18}
fontWeight="medium"
maxW={40}
textAlign="center"
mt="6"
>
Create a typebot
</Text>
</VStack>
</Button>
)
}

View File

@ -0,0 +1,158 @@
import { DashboardFolder } from '.prisma/client'
import {
Button,
Editable,
EditableInput,
EditablePreview,
MenuItem,
useDisclosure,
Text,
VStack,
IconButton,
Menu,
MenuButton,
MenuList,
useToast,
SkeletonText,
SkeletonCircle,
WrapItem,
} from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { FolderIcon, MoreVerticalIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useRouter } from 'next/router'
import React from 'react'
import { deleteFolder, updateFolder } from 'services/folders'
export const FolderButton = ({
folder,
onFolderDeleted,
onFolderRenamed,
}: {
folder: DashboardFolder
onFolderDeleted: () => void
onFolderRenamed: (newName: string) => void
}) => {
const router = useRouter()
const { setNodeRef, isOver } = useDroppable({
id: folder.id.toString(),
})
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const onDeleteClick = async () => {
const { error } = await deleteFolder(folder.id)
return error
? toast({
title: "Couldn't delete the folder",
description: error.message,
})
: onFolderDeleted()
}
const onRenameSubmit = async (newName: string) => {
if (newName === '' || newName === folder.name) return
const { error } = await updateFolder(folder.id, { name: newName })
return error
? toast({ title: 'An error occured', description: error.message })
: onFolderRenamed(newName)
}
const handleClick = () => {
router.push(`/typebots/folders/${folder.id}`)
}
return (
<Button
as={WrapItem}
ref={setNodeRef}
style={{ width: '225px', height: '270px' }}
paddingX={6}
whiteSpace={'normal'}
pos="relative"
cursor="pointer"
variant="outline"
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
justifyContent="center"
onClick={handleClick}
data-testid="folder-button"
>
<Menu>
<MenuButton
as={IconButton}
icon={<MoreVerticalIcon />}
aria-label="Show folder menu"
onClick={(e) => e.stopPropagation()}
colorScheme="blue"
variant="ghost"
size="lg"
pos="absolute"
top="5"
right="5"
/>
<MenuList>
<MenuItem
color="red"
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
>
Delete
</MenuItem>
</MenuList>
</Menu>
<VStack spacing="4">
<FolderIcon fontSize={50} color="blue.500" fill="blue.500" />
<Editable
defaultValue={folder.name}
fontSize="18"
onClick={(e) => e.stopPropagation()}
onSubmit={onRenameSubmit}
>
<EditablePreview _hover={{ bgColor: 'gray.300' }} px="2" />
<EditableInput textAlign="center" />
</Editable>
</VStack>
<ConfirmModal
isOpen={isOpen}
onClose={onClose}
confirmButtonLabel={'Delete'}
message={
<Text>
Are you sure you want to delete <strong>{folder.name}</strong>{' '}
folder? (Everything inside will be move to your dashboard)
</Text>
}
title={`Delete ${folder.name}?`}
onConfirm={onDeleteClick}
confirmButtonColor="red"
/>
</Button>
)
}
export const ButtonSkeleton = () => (
<Button
as={VStack}
mr={{ sm: 6 }}
mb={6}
style={{ width: '225px', height: '270px' }}
paddingX={6}
whiteSpace={'normal'}
pos="relative"
cursor="pointer"
variant="outline"
colorScheme={'gray'}
>
<VStack spacing="6" w="full">
<SkeletonCircle boxSize="45px" />
<SkeletonText noOfLines={2} w="full" />
</VStack>
</Button>
)

View File

@ -0,0 +1,133 @@
import React from 'react'
import {
Button,
Flex,
MenuItem,
Text,
useDisclosure,
useToast,
VStack,
WrapItem,
} from '@chakra-ui/react'
import { useDraggable } from '@dnd-kit/core'
import { useRouter } from 'next/router'
import { Typebot } from '@typebot/prisma'
import { isMobile } from 'services/utils'
import { MoreButton } from 'components/MoreButton'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { GlobeIcon, ToolIcon } from 'assets/icons'
import { deleteTypebot, duplicateTypebot } from 'services/typebots'
type ChatbotCardProps = {
typebot: Typebot
onTypebotDeleted: () => void
}
export const TypebotButton = ({
typebot,
onTypebotDeleted,
}: ChatbotCardProps) => {
const router = useRouter()
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: typebot.id.toString(),
})
const {
isOpen: isDeleteOpen,
onOpen: onDeleteOpen,
onClose: onDeleteClose,
} = useDisclosure()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const handleTypebotClick = () => {
router.push(
isMobile
? `/typebots/${typebot.id}/results/responses`
: `/typebots/${typebot.id}/edit`
)
}
const handleDeleteTypebotClick = async () => {
const { error } = await deleteTypebot(typebot.id)
if (error)
return toast({
title: "Couldn't delete typebot",
description: error.message,
})
onTypebotDeleted()
}
const handleDuplicateClick = async () => {
const { data: createdTypebot, error } = await duplicateTypebot(typebot)
if (error)
return toast({
title: "Couldn't duplicate typebot",
description: error.message,
})
if (createdTypebot) router.push(`/typebots/${createdTypebot?.id}`)
}
return (
<Button
as={WrapItem}
onClick={handleTypebotClick}
display="flex"
flexDir="column"
variant="outline"
colorScheme="gray"
color="gray.800"
w="225px"
h="270px"
mr={{ sm: 6 }}
mb={6}
rounded="lg"
whiteSpace="normal"
data-testid="typebot-button"
opacity={isDragging ? 0.2 : 1}
ref={setNodeRef}
{...listeners}
{...attributes}
>
<MoreButton pos="absolute" top="10px" right="10px">
<MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem>
<MenuItem color="red" onClick={onDeleteOpen}>
Delete
</MenuItem>
</MoreButton>
<VStack spacing="4">
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
bgColor={typebot.publishedTypebotId ? 'blue.500' : 'gray.400'}
color="white"
>
{typebot.publishedTypebotId ? (
<GlobeIcon fill="white" fontSize="20px" />
) : (
<ToolIcon fill="white" fontSize="20px" />
)}
</Flex>
<Text>{typebot.name}</Text>
</VStack>
<ConfirmModal
message={
<Text>
Are you sure you want to delete your Typebot &quot;{typebot.name}
&quot;.
<br />
All associated data will be lost.
</Text>
}
confirmButtonLabel="Delete"
onConfirm={handleDeleteTypebotClick}
isOpen={isDeleteOpen}
onClose={onDeleteClose}
/>
</Button>
)
}

View File

@ -0,0 +1,44 @@
import { Button, Flex, Text, VStack } from '@chakra-ui/react'
import { Typebot } from '.prisma/client'
import { GlobeIcon, ToolIcon } from 'assets/icons'
type Props = {
typebot: Typebot
}
export const TypebotCardOverlay = ({ typebot }: Props) => {
return (
<div
className="sm:mr-6 mb-6 focus:outline-none rounded-lg "
style={{ width: '225px', height: '270px' }}
>
<Button
display="flex"
flexDir="column"
variant="outline"
colorScheme="gray"
w="full"
h="full"
whiteSpace="normal"
>
<VStack spacing={4}>
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
bgColor={typebot.publishedTypebotId ? 'blue.500' : 'gray.400'}
color="white"
>
{typebot.publishedTypebotId ? (
<GlobeIcon fill="white" fontSize="20px" />
) : (
<ToolIcon fill="white" fontSize="20px" />
)}
</Flex>
<Text>{typebot.name}</Text>
</VStack>
</Button>
</div>
)
}

View File

@ -0,0 +1,77 @@
import { useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from '@chakra-ui/react'
type ConfirmDeleteModalProps = {
isOpen: boolean
onConfirm: () => Promise<unknown>
onClose: () => void
message: JSX.Element
title?: string
confirmButtonLabel: string
confirmButtonColor?: 'blue' | 'red'
}
export const ConfirmModal = ({
title,
message,
isOpen,
onClose,
confirmButtonLabel,
onConfirm,
confirmButtonColor = 'red',
}: ConfirmDeleteModalProps) => {
const [confirmLoading, setConfirmLoading] = useState(false)
const cancelRef = useRef(null)
const onConfirmClick = async () => {
setConfirmLoading(true)
try {
await onConfirm()
} catch (e) {
setConfirmLoading(false)
return setConfirmLoading(false)
}
setConfirmLoading(false)
onClose()
}
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title ?? 'Are you sure?'}
</AlertDialogHeader>
<AlertDialogBody>{message}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} colorScheme="gray">
Cancel
</Button>
<Button
colorScheme={confirmButtonColor}
onClick={onConfirmClick}
ml={3}
isLoading={confirmLoading}
>
{confirmButtonLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@ -0,0 +1,30 @@
import { PrismaClient } from '.prisma/client'
const prisma = new PrismaClient()
const teardownTestData = async () => prisma.user.deleteMany()
export const seedDb = async () => {
await teardownTestData()
await createUsers()
await createFolders()
return createTypebots()
}
const createUsers = () =>
prisma.user.createMany({
data: [
{ id: 'test1', email: 'test1@gmail.com', emailVerified: new Date() },
{ id: 'test2', email: 'test2@gmail.com', emailVerified: new Date() },
],
})
const createFolders = () =>
prisma.dashboardFolder.createMany({
data: [{ ownerId: 'test2', name: 'Folder #1', id: 'folder1' }],
})
const createTypebots = () =>
prisma.typebot.createMany({
data: [{ name: 'Typebot #1', ownerId: 'test2' }],
})

View File

@ -3,6 +3,7 @@ import {
FacebookSocialLogin,
GoogleSocialLogin,
} from 'cypress-social-logins/src/Plugins'
import { seedDb } from './database'
/// <reference types="cypress" />
/**
@ -14,6 +15,7 @@ const handler = (on: any) => {
GoogleSocialLogin: GoogleSocialLogin,
FacebookSocialLogin: FacebookSocialLogin,
GitHubSocialLogin: GitHubSocialLogin,
seed: seedDb,
})
}

View File

@ -1,37 +1,93 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import '@testing-library/cypress/add-commands'
import { signIn, signOut } from 'next-auth/react'
Cypress.Commands.add('logOutByApi', () =>
cy
.request('GET', `${Cypress.env('SITE_NAME')}/api/auth/csrf/login`)
.its('body')
.then((result) => {
cy.request('POST', `${Cypress.env('SITE_NAME')}/api/auth/signout`, {
csrfToken: result.csrfToken,
Cypress.Commands.add('signOut', () => {
cy.log(`🔐 Sign out`)
return cy.wrap(signOut({ redirect: false }), { log: false })
})
Cypress.Commands.add('signIn', (email: string) => {
cy.log(`🔐 Sign in as ${email}`)
return cy.wrap(signIn('credentials', { redirect: false, email }), {
log: false,
})
})
Cypress.Commands.add(
'mouseMoveBy',
{
prevSubject: 'element',
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(
subject: JQuery<HTMLElement>,
x: number,
y: number,
options?: { delay: number }
) => {
cy.wrap(subject, { log: false })
.then((subject) => {
const initialRect = subject.get(0).getBoundingClientRect()
const windowScroll = getDocumentScroll()
return [subject, initialRect, windowScroll] as const
})
})
.then(([subject, initialRect, initialWindowScroll]) => {
cy.wrap(subject)
.trigger('mousedown', { force: true })
.wait(options?.delay || 0, { log: Boolean(options?.delay) })
.trigger('mousemove', {
force: true,
clientX: Math.floor(
initialRect.left + initialRect.width / 2 + x / 2
),
clientY: Math.floor(
initialRect.top + initialRect.height / 2 + y / 2
),
})
.trigger('mousemove', {
force: true,
clientX: Math.floor(initialRect.left + initialRect.width / 2 + x),
clientY: Math.floor(initialRect.top + initialRect.height / 2 + y),
})
// .wait(1000)
.trigger('mouseup', { force: true })
.wait(250)
.then((subject: any) => {
const finalRect = subject.get(0).getBoundingClientRect()
const windowScroll = getDocumentScroll()
const windowScrollDelta = {
x: windowScroll.x - initialWindowScroll.x,
y: windowScroll.y - initialWindowScroll.y,
}
const delta = {
x: Math.round(
finalRect.left - initialRect.left - windowScrollDelta.x
),
y: Math.round(
finalRect.top - initialRect.top - windowScrollDelta.y
),
}
return [subject, { initialRect, finalRect, delta }] as const
})
})
}
)
const getDocumentScroll = () => {
if (document.scrollingElement) {
const { scrollTop, scrollLeft } = document.scrollingElement
return {
x: scrollTop,
y: scrollLeft,
}
}
return {
x: 0,
y: 0,
}
}

View File

@ -13,20 +13,33 @@
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
/// <reference types="cypress" />
declare global {
namespace Cypress {
interface Chainable {
/**
* Log out using the NextAuth API.
* @example cy.logOutByApi()
*/
logOutByApi(): Chainable<Response<any>>
signOut(): Chainable<any>
signIn(email: string): Chainable<any>
mouseMoveBy(
x: number,
y: number,
options?: { delay: number }
): Chainable<
[
Element,
{
initialRect: ClientRect
finalRect: ClientRect
delta: { x: number; y: number }
}
]
>
}
}
}
// Import commands.js using ES2015 syntax:
import '@testing-library/cypress/add-commands'
import './commands'
// Alternatively you can use CommonJS syntax:

View File

@ -1,7 +1,8 @@
describe('SignIn page', () => {
beforeEach(() => {
cy.logOutByApi()
cy.signOut()
})
it('can continue with Google', () => {
cy.visit('/signin')
const username = Cypress.env('GOOGLE_USER')
@ -98,6 +99,6 @@ const exectueSocialLogin = (
})
}
cy.visit('/typebots')
cy.findByText(`Hello ${username}`).should('exist')
cy.findByRole('button', { name: 'Create a folder' }).should('exist')
})
}

View File

@ -0,0 +1,49 @@
describe('Dashboard page', () => {
beforeEach(() => {
cy.task('seed')
cy.signOut()
})
it('should navigate correctly', () => {
cy.signIn('test1@gmail.com')
cy.visit('/typebots')
createFolder('My folder #1')
cy.findByTestId('folder-button').click()
cy.findByRole('heading', { name: 'My folder #1' }).should('exist')
createFolder('My folder #2')
cy.findByTestId('folder-button').click()
cy.findByRole('heading', { name: 'My folder #2' }).should('exist')
cy.findByRole('link', { name: 'Back' }).click()
cy.findByRole('heading', { name: 'My folder #1' }).should('exist')
cy.findByRole('link', { name: 'Back' }).click()
cy.findByRole('button', { name: 'Show folder menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByDisplayValue('My folder #2').should('exist')
cy.findByRole('button', { name: 'Show folder menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Delete' }).click()
cy.findByDisplayValue('My folder #2').should('not.exist')
})
it('should be droppable', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots')
cy.findByTestId('typebot-button').mouseMoveBy(-100, 0, {
delay: 120,
})
cy.visit('/typebots/folders/folder1')
cy.findByTestId('typebot-button').mouseMoveBy(-300, -100, {
delay: 120,
})
cy.visit('/typebots')
cy.findByDisplayValue('Folder #1').should('exist')
cy.findByText('Typebot #1').should('exist')
})
})
const createFolder = (folderName: string) => {
cy.findByRole('button', { name: 'Create a folder' }).click({ force: true })
cy.findByText('New folder').click({ force: true })
cy.findByDisplayValue('New folder').type(`${folderName}{enter}`)
}

View File

@ -8,6 +8,7 @@
"target": "es5",
"isolatedModules": false,
"allowJs": true,
"noEmit": true
"noEmit": true,
"downlevelIteration": true
}
}

View File

@ -0,0 +1,15 @@
import { PrismaClient } from '@typebot/prisma'
declare const global: { prisma: PrismaClient }
let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma

View File

@ -9,17 +9,22 @@
"cypress": "cypress open"
},
"dependencies": {
"@chakra-ui/css-reset": "^1.1.1",
"@chakra-ui/react": "^1.7.2",
"@dnd-kit/core": "^4.0.3",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@next-auth/prisma-adapter": "next",
"focus-visible": "^5.2.0",
"framer-motion": "^4",
"next": "^12.0.4",
"next-auth": "beta",
"nodemailer": "^6.7.1",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react-dom": "^17.0.2",
"swr": "^1.0.1",
"use-debounce": "^7.0.1"
},
"devDependencies": {
"@testing-library/cypress": "^8.0.2",

View File

@ -3,8 +3,9 @@ import { AppProps } from 'next/app'
import { SessionProvider } from 'next-auth/react'
import { ChakraProvider } from '@chakra-ui/react'
import { customTheme } from 'libs/chakra'
import 'assets/styles/routerProgressBar.css'
import { useRouterProgressBar } from 'libs/routerProgressBar'
import 'assets/styles/routerProgressBar.css'
import 'focus-visible/dist/focus-visible'
const App = ({ Component, pageProps }: AppProps) => {
useRouterProgressBar()

View File

@ -1,39 +1,87 @@
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { PrismaClient } from '@typebot/prisma'
import EmailProvider from 'next-auth/providers/email'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
import FacebookProvider from 'next-auth/providers/facebook'
import CredentialsProvider from 'next-auth/providers/credentials'
import prisma from 'libs/prisma'
import { Provider } from 'next-auth/providers'
import { User } from '@typebot/prisma'
const prisma = new PrismaClient()
const providers: Provider[] = [
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
]
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET)
providers.push(
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
})
)
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
providers.push(
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
})
)
if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET)
providers.push(
FacebookProvider({
clientId: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
})
)
if (process.env.NODE_ENV !== 'production')
providers.push(
CredentialsProvider({
name: 'Credentials',
credentials: {
email: {
label: 'Email',
type: 'email',
placeholder: 'email@email.com',
},
},
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: { email: credentials?.email },
})
return user
},
})
)
export default NextAuth({
adapter: PrismaAdapter(prisma),
secret: process.env.SECRET,
providers: [
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
}),
FacebookProvider({
clientId: process.env.FACEBOOK_CLIENT_ID ?? '',
clientSecret: process.env.FACEBOOK_CLIENT_SECRET ?? '',
}),
],
providers,
session: {
strategy: process.env.NODE_ENV === 'production' ? 'database' : 'jwt',
},
callbacks: {
jwt: async ({ token, user }) => {
user && (token.user = user)
return token
},
session: async ({ session, token }) => {
if (token.user) session.user = token.user as User
return session
},
},
})

View File

@ -0,0 +1,36 @@
import { DashboardFolder, User } from '@typebot/prisma'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const user = session.user as User
const parentFolderId = req.query.parentId
? req.query.parentId.toString()
: null
if (req.method === 'GET') {
const folders = await prisma.dashboardFolder.findMany({
where: {
ownerId: user.id,
parentFolderId,
},
})
return res.send({ folders })
}
if (req.method === 'POST') {
const data = JSON.parse(req.body) as Pick<DashboardFolder, 'parentFolderId'>
const folder = await prisma.dashboardFolder.create({
data: { ...data, ownerId: user.id, name: 'New folder' },
})
return res.send(folder)
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,37 @@
import { DashboardFolder } from '@typebot/prisma'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const id = req.query.id.toString()
if (req.method === 'GET') {
const folder = await prisma.dashboardFolder.findUnique({
where: { id },
})
return res.send({ folder })
}
if (req.method === 'DELETE') {
const folders = await prisma.dashboardFolder.delete({
where: { id },
})
return res.send({ folders })
}
if (req.method === 'PATCH') {
const data = JSON.parse(req.body) as Partial<DashboardFolder>
const folders = await prisma.dashboardFolder.update({
where: { id },
data,
})
return res.send({ typebots: folders })
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,34 @@
import { Typebot, User } from '@typebot/prisma'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const user = session.user as User
if (req.method === 'GET') {
const folderId = req.query.folderId ? req.query.folderId.toString() : null
const typebots = await prisma.typebot.findMany({
where: {
ownerId: user.id,
folderId,
},
})
return res.send({ typebots })
}
if (req.method === 'POST') {
const data = JSON.parse(req.body) as Typebot
const typebot = await prisma.typebot.create({
data: { ...data, ownerId: user.id },
})
return res.send(typebot)
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,31 @@
import { Typebot } from '.prisma/client'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const id = req.query.id.toString()
if (req.method === 'DELETE') {
const typebots = await prisma.typebot.delete({
where: { id },
})
return res.send({ typebots })
}
if (req.method === 'PATCH') {
const data = JSON.parse(req.body) as Partial<Typebot>
const typebots = await prisma.typebot.update({
where: { id },
data,
})
return res.send({ typebots })
}
return methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,18 @@
import withAuth from 'components/HOC/withUser'
import React from 'react'
import { Stack } from '@chakra-ui/layout'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { Seo } from 'components/Seo'
import { FolderContent } from 'components/dashboard/FolderContent'
const DashboardPage = () => {
return (
<Stack>
<Seo title="My typebots" />
<DashboardHeader />
<FolderContent folder={null} />
</Stack>
)
}
export default withAuth(DashboardPage)

View File

@ -0,0 +1,43 @@
import React, { useState } from 'react'
import { Button, Stack, useToast } from '@chakra-ui/react'
import { useUser } from 'services/user'
import { useRouter } from 'next/router'
import { Seo } from 'components/Seo'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { createTypebot } from 'services/typebots'
const TemplatesPage = () => {
const user = useUser()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const toast = useToast({
position: 'top-right',
status: 'error',
title: 'An error occured',
})
const handleCreateSubmit = async () => {
if (!user) return
setIsLoading(true)
const { error, data } = await createTypebot({
folderId: router.query.folderId?.toString() ?? null,
})
if (error) toast({ description: error.message })
if (data) router.push(`/typebots/${data.id}`)
setIsLoading(false)
}
return (
<Stack>
<Seo title="Templates" />
<DashboardHeader />
<Button ml={4} onClick={() => handleCreateSubmit()} isLoading={isLoading}>
Start from scratch
</Button>
</Stack>
)
}
export default TemplatesPage

View File

@ -0,0 +1,44 @@
import withAuth from 'components/HOC/withUser'
import React from 'react'
import { Flex, Stack } from '@chakra-ui/layout'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { Seo } from 'components/Seo'
import { FolderContent } from 'components/dashboard/FolderContent'
import { useRouter } from 'next/router'
import { useFolderContent } from 'services/folders'
import { Spinner, useToast } from '@chakra-ui/react'
const FolderPage = () => {
const router = useRouter()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { folder } = useFolderContent({
folderId: router.query.id?.toString(),
onError: (error) => {
toast({
title: "Couldn't fetch folder content",
description: error.message,
})
},
})
return (
<Stack>
<Seo title="My typebots" />
<DashboardHeader />
{!folder ? (
<Flex flex="1">
<Spinner mx="auto" />
</Flex>
) : (
<FolderContent folder={folder} />
)}
</Stack>
)
}
export default withAuth(FolderPage)

View File

@ -1,9 +0,0 @@
import withAuth, { withAuthProps } from 'components/HOC/withUser'
import { Text } from '@chakra-ui/react'
import React from 'react'
const TypebotsPage = ({ user }: withAuthProps) => {
return <Text data-testid="authenticated">Hello {user?.email}</Text>
}
export default withAuth(TypebotsPage)

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

View File

@ -0,0 +1,4 @@
import { NextApiResponse } from 'next'
export const methodNotAllowed = (res: NextApiResponse) =>
res.status(405).json({ message: 'Method Not Allowed' })

View File

@ -0,0 +1,69 @@
import { DashboardFolder } from '.prisma/client'
import useSWR from 'swr'
import { fetcher, sendRequest } from './utils'
export const useFolders = ({
parentId,
onError,
}: {
parentId?: string
onError: (error: Error) => void
}) => {
const params = new URLSearchParams(
parentId ? { parentId: parentId.toString() } : undefined
)
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
`/api/folders?${params}`,
fetcher
)
if (error) onError(error)
return {
folders: data?.folders,
isLoading: !error && !data,
mutate,
}
}
export const useFolderContent = ({
folderId,
onError,
}: {
folderId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ folder: DashboardFolder }, Error>(
`/api/folders/${folderId}`,
fetcher
)
if (error) onError(error)
return {
folder: data?.folder,
isLoading: !error && !data,
mutate,
}
}
export const createFolder = async (
folder: Pick<DashboardFolder, 'parentFolderId'>
) =>
sendRequest<DashboardFolder>({
url: `/api/folders`,
method: 'POST',
body: folder,
})
export const deleteFolder = async (id: string) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'DELETE',
})
export const updateFolder = async (
id: string,
folder: Partial<DashboardFolder>
) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'PATCH',
body: folder,
})

View File

@ -0,0 +1,69 @@
import { Typebot } from '@typebot/prisma'
import useSWR from 'swr'
import { fetcher, sendRequest } from './utils'
export const useTypebots = ({
folderId,
onError,
}: {
folderId?: string
onError: (error: Error) => void
}) => {
const params = new URLSearchParams(
folderId ? { folderId: folderId.toString() } : undefined
)
const { data, error, mutate } = useSWR<{ typebots: Typebot[] }, Error>(
`/api/typebots?${params}`,
fetcher
)
if (error) onError(error)
return {
typebots: data?.typebots,
isLoading: !error && !data,
mutate,
}
}
export const createTypebot = async ({
folderId,
}: Pick<Typebot, 'folderId'>) => {
const typebot = {
folderId,
name: 'My typebot',
}
return sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: typebot,
})
}
export const duplicateTypebot = async ({
folderId,
ownerId,
name,
}: Typebot) => {
const typebot = {
folderId,
ownerId,
name: `${name} copy`,
}
return sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: typebot,
})
}
export const deleteTypebot = async (id: string) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'DELETE',
})
export const updateTypebot = async (id: string, typebot: Partial<Typebot>) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'PATCH',
body: typebot,
})

View File

@ -0,0 +1,7 @@
import { User } from '@typebot/prisma'
import { useSession } from 'next-auth/react'
export const useUser = (): User | undefined => {
const { data } = useSession()
return data?.user as User | undefined
}

View File

@ -0,0 +1,32 @@
export const fetcher = async (input: RequestInfo, init?: RequestInit) => {
const res = await fetch(input, init)
return res.json()
}
export const isMobile =
typeof window !== 'undefined' &&
window.matchMedia('only screen and (max-width: 760px)').matches
export const sendRequest = async <ResponseData>({
url,
method,
body,
}: {
url: string
method: string
body?: Record<string, unknown>
}): Promise<{ data?: ResponseData; error?: Error }> => {
try {
const response = await fetch(url, {
method,
mode: 'cors',
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) throw new Error(response.statusText)
const data = await response.json()
return { data }
} catch (e) {
console.error(e)
return { error: e as Error }
}
}

View File

@ -4,7 +4,7 @@
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,

View File

@ -2,7 +2,7 @@
"name": "viewer",
"packageManager": "yarn@3.1.0",
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"